Spaces:
Running
A newer version of the Gradio SDK is available: 6.28.0
Surf forecast tab
The surf forecast tab in app/ui.py:build_demo() scores the break
selected on the encyclopedia tab. Two stages:
- Stage 1 β deterministic charts (
fetch_forecast): Open-Meteo numbers in,scoring.pynumbers out. No agent/LLM in this path. - Stage 2 β narrated report (
generate_reports, opt-in): the LLM narrates the Stage-1 scores only β it never invents swell/wind/score values.
Pipeline
pick break (tab 1, break_dd -> selected_break gr.State)
-> press "Get forecast (charts)" (tab 2, fetch_btn)
-> fetch_forecast() [app/forecast_handlers.py]
-> lazy `from app import surf_forecast` (direct package import)
-> get_scored_week(break, skill, days=7)
-> adapters.enriched_to_scoring_spot(break) # schema -> scoring shape
-> forecasts.get_forecast(lat, lng, days) # marine + wind, cached
-> scoring.score_week(forecast, spot, skill) # 0-10 per hour
-> best_window / daily_best / build_score_fig / build_waves_fig / build_wind_fig
-> scored_payload gr.State {break, skill, spot, scored, daily, best, days}
-> press "Generate surf report" (tab 2, report_btn) [optional]
-> generate_reports() [app/forecast_handlers.py]
-> lazy `from app import generate_surf_report` (direct package import)
-> generate_surf_report_stream(break, skill, daily, best, days)
fetch_forecast fixes days = 7 (no days slider in the UI;
get_scored_week still clamps any days arg to 1β7). It shows
gr.Progress steps and writes human-readable status into status_box
plus a tool-call trace into report_telemetry_box; any exception
surfaces there with empty figs (no traceback in UI). An empty scored
list disables the report path (scored_payload = None).
Adapter (app/adapters.py)
scoring.py expects the old spot shape; the enriched schema differs,
so the adapter bridges it:
| scoring expects | enriched has | mapping |
|---|---|---|
ideal_swell.direction: "SE" |
idealSwell.direction: ["E","ESE","SE"] |
/-join list (_dir_to_deg parses / as cyclic mean); empty β "E" |
ideal_swell.size_ft_min/max |
idealSwell.sizeRangeFt.{min,max} |
float cast (missing β 0.0) |
ideal_wind.direction |
idealWind.direction: [...] |
same /-join; empty β "E" |
ideal_wind.strength_kt_max |
only idealWind.type (offshore/β¦) |
flat 15.0 (DEFAULT_WIND_MAX_KT) β schema has no knots value; final limit is min(15, skill_profile_max) in _score_wind |
skill beginner/intermediate/advanced/expert |
+ pro-only |
pro-only/pro only/pro β expert; unknown/None β intermediate |
Helpers: normalize_skill(), break_skill() (break's own tier),
get_coords() (mirrors app/maps.py _lat/_lng, None on missing).
Forecast client (app/forecasts.py)
- Two Open-Meteo endpoints merged on
timeby_build_normalized:marine-api.open-meteo.com(hourlywave_height,wave_period,wave_direction,wind_wave_height,swell_wave_height) +api.open-meteo.com(hourlywind_speed_10min km/h, converted to knots inscore_week, +wind_direction_10m). Marine serves no wind. - Grid snap: coords shift ~0.13Β° seaward (
_seaward_offset, rounded to 2 dp by_round_coords) so lookups land on a marine grid point β TAS (lat <= -39.5) shifts south, east coast (lon >= 147) shifts east, west coast (lon <= 125) shifts west, otherwise (SA/VIC south coast) shifts south. - Cache:
.cache/forecasts/<lat>_<lon>_<days>.jsonenvelope{fetched_at, data}(key from the raw spot coords; the seaward offset is a fetch detail only). TTL 6h (CACHE_TTL_SECONDS). Fresh cache served without network (written atomically via tmp + rename); on API failure the stale entry is served as fallback (legacy pre-envelope files return withfetched_at = 0so they still work as stale fallback); raises only with no usable data at all.
Scoring (app/scoring.py)
Per hour (score_hour), weights swell_size 0.30 / swell_direction 0.20 / wind 0.30 / period 0.20 (redistributed when direction missing):
- size β 10 inside spot's
[min,max], Gaussian falloff outside (Ο = max(1.0, min*0.4)below,Ο = max(1.5, max*0.5)above), multiplied by skill comfort multiplier β 0 abovemax_safe_size_ft. - direction β Gaussian on angular diff to ideal
(
Ο = 45/1.5 = 30Β°, so 45Β° off β 3.2/10). - wind β mean of speed (10 if β€ limit else 0; glassy β€5kt always 10)
and direction (
10 * max(0, cos(diff/2)), i.e. perfect offshore β 10, 180Β° off (onshore) β 0). Limit =min(spot 15kt, skill max). - period β 0 at β€4s, linear to 10 at β₯14s, never penalised above.
score_week skips hours with nulls and converts wind_speed_10m
km/h β kt (Γ 0.539957). Skill profiles (ft / kt / s):
| skill | comfort ft | max safe ft | max wind kt |
|---|---|---|---|
| beginner | 1.0β3.5 | 4.5 | 12 |
| intermediate | 2.0β6.5 | 8.5 | 18 |
| advanced | 3.0β12.0 | 18.0 | 25 |
| expert (+pro-only) | 4.0β30.0 | 50.0 | 35 |
UI outputs (app/surf_forecast.py builders)
get_scored_week returns {forecast, scored, spot, skill, lat, lng}.
daily_best groups by date (first 10 chars of time) and returns
{date, time, score, wave_height_m, wave_period_s, wind_speed_kt, wind_direction_deg} per day. best_window is the single
highest-scoring hour.
best_mdhero:**score/10 @ time** β H m @ P s, wind Wkt (degΒ°).build_score_figβ score bars coloured red β green by quality (β₯8 dark green, β₯6 light green, β₯4 yellow, β₯2 orange, else red) + gold β marker on the best hour.build_waves_figβwave_height_mfilled area (blue, left axis) +wave_period_sline (orange, right axis).build_wind_figβ arrows only, no y-axis: colour = direction quality vs the spot's ideal offshore (green β€45Β°, yellow cross β€135Β°, red onshore above that; dark blue when the spot has no parseable direction), size = strength (22 β 33pt over 0β30kt), subsampled to ~28 arrows. Arrows point where the wind blows TO; exact kt + compass on hover.- All three share
_strip_layoutstyling (compact heights, unified hover, white plot bg).build_components_figis retired (returnsNo forecast data) β usebuild_score_fig. - Empty
scoredβ figs annotatedNo forecast data, report disabled.
Stage-2 report (app/generate_surf_report.py)
Same framework as break generation (single-shot InferenceClient
call, nvidia/NVIDIA-Nemotron-3.5-Lightning-30B-A3B-BF16 via
provider="fireworks-ai", deliberately NOT a smolagents CodeAgent).
Speed-first: the prompt carries ONLY the daily bests (β€7 lines) + the
single best window + a one-line break summary (ideals + up to 3
hazards) β never the full hourly table or full break record.
- Output is plain markdown, NOT JSON: one
-bullet per day in date order (date, score, height/period, wind, short outlook phrase) plus a final**Recommendation: <date + time> -- <reason>.**line.max_tokens=500,temperature=0.3. - Reasoning disabled at the API level (
EXTRA_BODY = {"reasoning_effort": "none"}+/no_thinksystem prompt). Markers@@REPORT@@ β¦ @@END@@fence the answer;_clean_outputstrips<think>blocks, fences, and plain-text planning sentences ("we need toβ¦") live during streaming. generate_surf_report_streamyields the accumulated cleaned report per delta;statsreportsreasoning_chars(hidden channel, never displayed) vscontent_charsso callers can prove the visible text is the final report.app/forecast_handlers.pygenerate_reportsyields an instant "Contacting report modelβ¦" placeholder first so the button never looks dead, then streams. LLM failure keeps the deterministic charts β only the report box shows the error.
Behaviour + limits
- Explicit Get forecast button β no auto-fetch on pick (saves API
calls). Skill dropdown defaults to the selected break's tier
(
pro-onlyβexpert); window fixed at 7 days. Report is a second explicit opt-in after inspecting the charts. - Missing coords β
ValueErrorβ status error. No-break β warning state, no fetch. No scored hours β warning, report disabled. - Custom β break works identically (same schema + coords).
- Known limits: no tide curves (Open-Meteo has none β
idealTideis qualitative only); flat 15kt wind tolerance for all spots; swell direction is a cyclic mean of the ideal list, not a per-peak model.