wavereader / README.md
wavereader-ci
deploy: prepend Space config to README (from main)
65a92b4
|
Raw History Blame Contribute Delete
14.5 kB
---
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