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.