File size: 14,548 Bytes
65a92b4
 
 
 
 
 
 
 
 
 
 
 
 
 
64d8003
 
a1f52bf
 
 
 
 
 
 
 
 
 
 
2dfa899
a1f52bf
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e5f1ed2
a1f52bf
e5f1ed2
a1f52bf
 
 
 
 
 
 
e5f1ed2
a1f52bf
 
e5f1ed2
a1f52bf
64d8003
a1f52bf
 
 
 
64d8003
a1f52bf
 
 
5ba47e2
a1f52bf
 
 
5ba47e2
 
 
a1f52bf
64d8003
 
5ba47e2
 
a1f52bf
64d8003
 
a1f52bf
64d8003
5ba47e2
e5f1ed2
 
5ba47e2
64d8003
a1f52bf
 
 
 
 
64d8003
2dfa899
 
 
c5672b8
 
 
d4c9a3c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2dfa899
c5672b8
2dfa899
 
 
 
 
 
a1f52bf
5ba47e2
 
a1f52bf
e5f1ed2
a1f52bf
 
 
 
 
 
 
 
e5f1ed2
a1f52bf
e5f1ed2
5ba47e2
a1f52bf
 
 
 
5ba47e2
a1f52bf
5ba47e2
a1f52bf
 
5ba47e2
2dfa899
a1f52bf
64d8003
 
a1f52bf
 
 
 
 
 
 
 
 
 
 
 
 
5ba47e2
a1f52bf
 
 
 
 
 
 
5ba47e2
a1f52bf
5ba47e2
a1f52bf
e5f1ed2
a1f52bf
 
 
 
 
 
 
 
5ba47e2
a1f52bf
18911ed
5ba47e2
18911ed
a1f52bf
 
 
 
 
 
2dfa899
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
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
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
---
title: wave~reader
emoji: πŸ„
colorFrom: blue
colorTo: indigo
sdk: gradio
sdk_version: 6.26.0
python_version: "3.14"
app_file: app.py
license: mit
pinned: false
short_description: Surf-forecast world model for the Australian coast
---

# wave~reader

**A surf-forecasting world model for the Australian coast: 238 breaks, 168
deterministic scores per spot, a 3D seafloor built from real GEBCO
bathymetry β€” and one open model that narrates the numbers instead of
inventing them.**

wave~reader is a single-page Gradio app, built in September 2026
as an entry for the **GTC Berlin Golden Ticket Developer Contest**. I have
just moved to Berlin; I wanted to build something to make me enjoy ML and
engineering again.

> [Built for GTC Berlin](#built-for-the-gtc-berlin-golden-ticket-developer-contest-) β€”
> NVIDIA open weights on Hugging Face rails. #NVIDIAGTC

## One rule holds the whole app together

**The LLM never owns numbers.** Every wave height, wind reading, and 0–10
score on screen comes out of plain, deterministic Python β€”
`wavereader/scoring.py`, `wavereader/openmeteo.py`, `wavereader/tools.py` β€”
running over Open-Meteo marine data and the enriched break catalogue. The
model's contract, written into its system prompt, is to interpret those
numbers, name the tool that produced them, and never compute a forecast
itself. The agent can _recommend_; it can't _hallucinate a swell_.

That split β€” a deterministic core with the model bolted on as an
interpreter β€” is the entire architecture, and it's what makes an
open-weights model safe to plan a surf trip around.

## One screen, four instruments

There are no tabs. One surface, four synchronized instruments, one source
of truth (`selected`): picking a spot re-aims everything at once.

| Instrument                  | What it does                                                                                                                                                                                                                                                                                                                                                                           |
| --------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Map lens** (left)         | All 238 breaks on a Plotly map, filtered by state / region / skill. A pick loads the full field guide and re-aims the forecast, world model, and climatology.                                                                                                                                                                                                                          |
| **Forecast story** (center) | The engines spin up in visible stages β€” status lines name the engine and its latency. You get a 0–10 score for every hour of the next 7 days, score / swell / wind strips with weekend shading, then a **smoothed 3D seafloor with an animated swell layer** riding the forecast's dominant period (press β–Ά swell). An optional streamed narrator writes it up β€” from the scores only. |
| **Intel rail** (right)      | Field guide (peak type, ideal swell / wind / tide, hazards, crowd factor), a **5-year ERA5 swell climate rose** that audits the dataset's own claims, a wetsuit hint, and nearby surf cams.                                                                                                                                                                                            |
| **Agent bar** (full width)  | Chat: _"where's it going to be good in NSW this weekend?"_ A smolagents `ToolCallingAgent` on Nemotron 3 Ultra finds spots, ranks regions, explains score breakdowns, and suggests similar breaks. Every tool card shows its **latency in ms**; charts render mid-answer straight from tool payloads. Hard budget: 6 steps, 700 tokens a turn.                                         |

## Machinery you can watch

The app wears its internals on its sleeve: a strip across the top shows
every engine (🌍 world model Β· πŸ“‘ feeds Β· 🌑 climate Β· βš™ scorer Β· 🧠 LLM Β·
πŸ”Œ MCP) with live timings, and slow work loads in stages so the charts are
never blocked. Measured on a laptop against the disk cache:

| Engine                                       | Cold     | Warm       | Where                     |
| -------------------------------------------- | -------- | ---------- | ------------------------- |
| βš™ scoring engine (168 hourly 0–10 scores)    | ~300 ms  | **0.8 ms** | `wavereader/scoring.py`   |
| πŸ“‘ Open-Meteo marine + wind feed             | ~300 ms  | **~1 ms**  | `wavereader/openmeteo.py` |
| 🌍 GEBCO world model (10Γ—10 bathymetry grid) | ~1.3 s   | **~1 ms**  | `wavereader/seafloor.py`  |
| 🧠 agent tool call (`score_week` over MCP)   | ~2 ms    | **0.7 ms** | `wavereader/tools.py`     |
| Chart trio (score / swell / wind)            | 12–14 ms | β€”          | `ui/charts/`              |

Forecast charts land in ~80 ms while the world model resolves in a
parallel thread. `scripts/warm_caches.py` pre-fills every cache before a
demo, so a cold start never happens on camera. The seafloor view bicubically
upsamples the 10Γ—10 GEBCO grid to 41Γ—41 and animates a translucent swell
surface at the forecast's dominant height and period β€” a world model you
can watch working. (Smoothing and animation are display-only; statistics
always come from the raw grid.)

## The stack: NVIDIA open weights on Hugging Face rails

Every model call in the repo goes through one door β€” **Hugging Face
Inference Providers**, served by DeepInfra β€” running
[`nvidia/NVIDIA-Nemotron-3-Ultra-550B-A55B-BF16`](https://huggingface.co/nvidia/NVIDIA-Nemotron-3-Ultra-550B-A55B-BF16),
the open-weights Ultra of NVIDIA's Nemotron 3 family. No private APIs, no
proprietary endpoints, one `HF_TOKEN`. Around the model, everything is
open too: Open-Meteo forecasts, ERA5 reanalysis, GEBCO 2020 bathymetry via
OpenTopoData, and a scoring engine you can read in one sitting.

With NVIDIA and Hugging Face now one family, this app got to be an early
wedding photo. πŸŽ‰

## The app is the tool: MCP built in

`app.py` launches with `mcp_server=True`, so any MCP client (Claude
Desktop, opencode, …) can drive the deterministic engines directly at
`<space-or-local-url>/gradio_api/mcp/`. The public surface is exactly
three typed tools β€” the same functions the agent calls internally:

- `score_week` β€” hourly 0–10 scores, daily bests, best window for one break
- `rank_region_week` β€” every break in a region, ranked best-first
- `explain_score` β€” the size / direction / wind / period breakdown of one scored hour

Verify with `curl <url>/gradio_api/mcp/schema`. Everything else in the UI
is registered with `api_name=False` on purpose β€” the MCP surface stays
clean.

## Quickstart

Needs [uv](https://docs.astral.sh/uv/) and Python β‰₯ 3.14.

```sh
uv sync
cp .env.example .env   # fill in HF_TOKEN (https://huggingface.co/settings/tokens)
uv run app.py
```

Or skip the UI and drive the agent from Python:

```sh
uv run python -c "from wavereader.agent import run_stream; \
  [print(e.get('text', e.get('name', '')), end='') for e in run_stream('best beginner morning this weekend in VIC?')]"
```

`HF_TOKEN` is only needed for the agent and the narrator β€” the forecast
charts, world model, and MCP tools run token-free with plain network
access to Open-Meteo / OpenTopoData and a disk cache under `.cache/`.
On a HF Space the secret is the fallback; the agent panel's BYO-token box
is session-only and never logged.

## Deploying to HF Spaces

The app is live at [huggingface.co/spaces/lucashudsn/wavereader](https://huggingface.co/spaces/lucashudsn/wavereader).
The Space _is_ this git repo β€” the `space` remote points at it. Spaces
read their config from YAML frontmatter at the top of `README.md`, but
GitHub renders that block as an ugly metadata table, so it lives in
`space-config.yaml` instead and the deploy flow prepends it in a
throwaway commit at push time (the repo README stays clean; HF gets its
config). Keep `sdk_version` in lockstep with the gradio pin in
`requirements.txt`. If you change Space settings through the web UI, HF
commits them into the Space's `README.md` β€” mirror anything you care
about back into `space-config.yaml`.

**Two ways to ship β€” deploys work from any branch, not just `main`:**

1. **Automatic, on every push to `main`.** The
   `.github/workflows/deploy-space.yml` action deploys `main` to the
   Space using the repo's `HF_TOKEN` Actions secret. Merge and walk
   away; watch progress under the repo's _Actions_ tab and the Space's
   _Logs β†’ Build_.

2. **Manual, any branch.** From a clean tree with your work committed:

   ```sh
   scripts/deploy_space.sh              # deploy the branch you're on
   scripts/deploy_space.sh some-branch  # deploy any committed ref
   git push origin some-branch          # keep GitHub in sync
   ```

   The script builds its throwaway commit in a temp worktree, so your
   checkout is never touched; it force-pushes that tree to the Space's
   `main` (deploys are throwaway commits β€” only the tip matters to the
   build). The push asks for credentials: username `lucashudsn`,
   password = an HF token with write access
   (https://huggingface.co/settings/tokens).

One-time CI setup: the Action needs `HF_TOKEN` as a **GitHub** repo
secret (_Settings β†’ Secrets and variables β†’ Actions_, or
`gh secret set HF_TOKEN`). The Space's own `HF_TOKEN` is a separate
secret, already set. Builds take a few minutes; watch them under the
Space's _Logs β†’ Build_ tab or poll
`HfApi().get_space_runtime("lucashudsn/wavereader")`.

Secrets live in the Space's _Settings β†’ Variables and secrets_ β€”
`HF_TOKEN` is already set, which is what lets the agent and narrator work
for visitors. `WR_MODEL` / `WR_PROVIDER` / `WR_BASE_URL` can be added the
same way if you ever want to reroute the LLM. Caches under `.cache/` are
ephemeral on the Space and rebuild lazily after a restart; nothing else
needs doing.

## How it's laid out

```
app.py                           # HF Space entry: single-page UI + gr.api MCP tools
ui/                              # the front end
  app.py                         # layout + wiring (≀3 gr.State; Bells on load)
  panels/{lens,story,intel,agent}.py  # map lens | forecast story | intel rail | agent bar
  charts/{map,strip,score,seafloor,climate,_style}.py  # Plotly builders, one theme
  contracts.py                   # SELECT/FETCH/AGENT key contracts β€” no output-order drift
  _compat.py                     # backend access: v2 core β†’ legacy.app.* β†’ stubs
  theme.py                       # APP_CSS β€” IBM Plex Mono lo-fi identity
wavereader/                      # pure core: typed, tested, zero Gradio imports
  breaks.py openmeteo.py scoring.py seafloor.py climate.py
  tools.py                       # the deterministic tool functions (agent + MCP)
  agent.py                       # smolagents ToolCallingAgent, typed event stream
  narrate.py llm.py              # streamed report writer; one InferenceClient factory
scripts/
  warm_caches.py                 # demo hot start: forecast + seafloor caches
  build_climate.py               # ERA5 5-yr climatology builder (re-embeds catalogue)
  check_coords.py check_coast.py # coordinate + GEBCO coastline maintenance
  eval_agent.py                  # agent evaluation harness
data/
  australia-surf-breaks-enriched.json  # 238 schema-valid breaks, ERA5 embedded
  surf-break-schema.json               # the conformance contract
  surf-break-example-bells.json        # prompt worked example
  surf-cams.json                       # camera links keyed name | state | region
documents/
  ENCYCLOPEDIA.md FORECAST.md SURFAGENT.md
legacy/                          # frozen v1 front end, kept as the compat fallback
```

Cheap checks: `uv run pytest -q` (offline fixtures, including an agent
dry-run through an injected fake model) and `uv run python -c "import app"`.

## Where the data came from

The break catalogue was generated by running **Nemotron 3.5 Lightning**
once per break through the v1 pipeline (now archived in `legacy/`): one
chat call per spot at temperature 0.4, with the JSON schema and the Bells
Beach worked example loaded from `data/` into the prompt.
`state` / `region` are pinned from the input list, never taken from the
model, and every record validates against
`data/surf-break-schema.json` with strict enums for swell, wind, tide,
skill, and hazards. The batch runner is resumable.

After the build, `scripts/build_climate.py` gave each break a **5-year
ERA5 swell profile** (embedded in the record itself β€” 236 of 238; two
duplicate Gnaraloo rows miss out) and `scripts/check_coords.py` /
`scripts/check_coast.py` keep coordinates honest against OSM and the GEBCO
shoreline. The climate rose in the intel rail uses those profiles to audit
the dataset: where the model's claimed "ideal swell" disagrees with five
years of observed reanalysis, the data wins.

## The forecast pipeline, end to end

1. **Deterministic core.** break β†’ `scoring.py` β†’
   `openmeteo.get_forecast` (Open-Meteo marine + wind, disk-cached,
   snapped seaward to the right grid cell) β†’ `score_week` β†’ 168 hourly
   scores β†’ Plotly strips + best-window hero. In parallel, the **world
   model** pulls the GEBCO 2020 grid around the takeoff zone, classifies
   the shelf shape, and renders depth, 3D, and transects with the
   animated swell layer.
2. **Narrator (optional).** The prompt carries only the daily bests
   (≀7 lines) plus the best window; Nemotron 3 Ultra streams a dot-point
   write-up of exactly those numbers. Nothing more.

Full detail in [`documents/FORECAST.md`](documents/FORECAST.md).

## Built for the GTC Berlin Golden Ticket Developer Contest 🎟️

wave~reader is a contest entry: an app that could only exist because
NVIDIA ships frontier-class open weights and Hugging Face turns them into
one-token inference. It leans on both halves deliberately β€” Nemotron 3
Ultra for language, deterministic Python for physics β€” and shows its work
on screen the whole way.

Tagging @Merve Noyan β€” thanks for the nudge. #NVIDIAGTC