Spaces:
Running
Running
| # 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.py` numbers 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 `time` by `_build_normalized`: | |
| `marine-api.open-meteo.com` (hourly `wave_height`, `wave_period`, | |
| `wave_direction`, `wind_wave_height`, `swell_wave_height`) + | |
| `api.open-meteo.com` (hourly `wind_speed_10m` in km/h, converted to | |
| knots in `score_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>.json` envelope | |
| `{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 with `fetched_at = 0` so 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 above `max_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_md` hero: `**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_m` filled area (blue, left axis) + | |
| `wave_period_s` line (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_layout` styling (compact heights, unified | |
| hover, white plot bg). `build_components_fig` is retired (returns | |
| `No forecast data`) β use `build_score_fig`. | |
| - Empty `scored` β figs annotated `No 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_think` system prompt). Markers | |
| `@@REPORT@@ β¦ @@END@@` fence the answer; `_clean_output` strips | |
| `<think>` blocks, fences, and plain-text planning sentences | |
| ("we need toβ¦") live during streaming. | |
| - `generate_surf_report_stream` yields the accumulated cleaned report | |
| per delta; `stats` reports `reasoning_chars` (hidden channel, never | |
| displayed) vs `content_chars` so callers can prove the visible text | |
| is the final report. `app/forecast_handlers.py` `generate_reports` yields 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 β `idealTide` is | |
| qualitative only); flat 15kt wind tolerance for all spots; swell | |
| direction is a cyclic mean of the ideal list, not a per-peak model. | |