Spaces:
Running
Running
File size: 8,562 Bytes
18911ed 5ba47e2 dead2da 18911ed dead2da 5ba47e2 dead2da 18911ed dead2da 5ba47e2 dead2da 18911ed dead2da 18911ed dead2da 18911ed dead2da 18911ed dead2da 18911ed 5ba47e2 18911ed dead2da 18911ed dead2da 18911ed dead2da 18911ed dead2da 18911ed dead2da 18911ed dead2da 18911ed dead2da 18911ed dead2da 18911ed dead2da 5ba47e2 dead2da 5ba47e2 dead2da 18911ed dead2da 18911ed dead2da 18911ed | 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 | # 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.
|