lucashudsn commited on
Commit
f615407
·
1 Parent(s): d3271f7

polish: scoring null-hardening, spot adapter fix, UI loading states + MCP badge copy

Browse files
requirements.txt ADDED
@@ -0,0 +1,258 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ Resolved 83 packages in 3ms
2
+ # This file was autogenerated by uv via the following command:
3
+ # uv export --no-hashes --format requirements-txt --no-dev
4
+ annotated-doc==0.0.5
5
+ # via
6
+ # fastapi
7
+ # typer
8
+ annotated-types==0.8.0
9
+ # via pydantic
10
+ anyio==4.15.1
11
+ # via
12
+ # gradio
13
+ # httpx
14
+ # httpx2
15
+ # mcp
16
+ # openai
17
+ # sse-starlette
18
+ # starlette
19
+ attrs==26.1.0
20
+ # via
21
+ # jsonschema
22
+ # referencing
23
+ audioop-lts==0.2.2
24
+ # via gradio
25
+ brotli==1.2.0
26
+ # via gradio
27
+ certifi==2026.7.22
28
+ # via
29
+ # httpcore
30
+ # httpx
31
+ # requests
32
+ cffi==2.1.1 ; platform_python_implementation != 'PyPy'
33
+ # via cryptography
34
+ charset-normalizer==3.5.1
35
+ # via requests
36
+ click==8.5.0
37
+ # via
38
+ # huggingface-hub
39
+ # uvicorn
40
+ colorama==0.4.6 ; sys_platform == 'win32'
41
+ # via
42
+ # tqdm
43
+ # typer
44
+ cryptography==50.0.1
45
+ # via pyjwt
46
+ fastapi==0.141.1
47
+ # via gradio
48
+ filelock==3.32.5
49
+ # via huggingface-hub
50
+ fsspec==2026.7.0
51
+ # via
52
+ # gradio-client
53
+ # huggingface-hub
54
+ gradio==6.26.0
55
+ # via wavereader
56
+ gradio-client==2.6.1
57
+ # via
58
+ # gradio
59
+ # hf-gradio
60
+ groovy==0.1.2
61
+ # via gradio
62
+ h11==0.16.0
63
+ # via
64
+ # httpcore
65
+ # httpcore2
66
+ # uvicorn
67
+ hf-gradio==0.4.1
68
+ # via gradio
69
+ hf-xet==1.6.0 ; platform_machine == 'AMD64' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'arm64' or platform_machine == 'x86_64'
70
+ # via huggingface-hub
71
+ httpcore==1.0.9
72
+ # via httpx
73
+ httpcore2==2.12.0 ; sys_platform != 'emscripten'
74
+ # via httpx2
75
+ httpx==0.28.1
76
+ # via
77
+ # gradio
78
+ # gradio-client
79
+ # huggingface-hub
80
+ # mcp
81
+ # safehttpx
82
+ # wavereader
83
+ httpx-sse==0.4.3
84
+ # via mcp
85
+ httpx2==2.12.0
86
+ # via openai
87
+ httpx2-jsfetch==1.0 ; sys_platform == 'emscripten'
88
+ # via httpx2
89
+ huggingface-hub==1.30.0
90
+ # via
91
+ # gradio
92
+ # gradio-client
93
+ # smolagents
94
+ # wavereader
95
+ idna==3.19
96
+ # via
97
+ # anyio
98
+ # httpx
99
+ # httpx2
100
+ # requests
101
+ jinja2==3.1.6
102
+ # via
103
+ # gradio
104
+ # smolagents
105
+ jiter==0.16.0
106
+ # via openai
107
+ jsonschema==4.26.0
108
+ # via
109
+ # mcp
110
+ # wavereader
111
+ jsonschema-specifications==2025.9.1
112
+ # via jsonschema
113
+ markdown-it-py==4.2.0
114
+ # via rich
115
+ markupsafe==3.0.3
116
+ # via
117
+ # gradio
118
+ # jinja2
119
+ mcp==1.30.0
120
+ # via gradio
121
+ mdurl==0.1.2
122
+ # via markdown-it-py
123
+ narwhals==2.25.0
124
+ # via plotly
125
+ numpy==2.5.3
126
+ # via
127
+ # gradio
128
+ # pandas
129
+ openai==3.8.0
130
+ # via smolagents
131
+ orjson==3.12.0
132
+ # via gradio
133
+ packaging==26.3
134
+ # via
135
+ # gradio
136
+ # gradio-client
137
+ # huggingface-hub
138
+ # plotly
139
+ pandas==3.0.5
140
+ # via
141
+ # gradio
142
+ # wavereader
143
+ pillow==12.3.0
144
+ # via
145
+ # gradio
146
+ # smolagents
147
+ plotly==7.0.0
148
+ # via wavereader
149
+ pycparser==3.0 ; implementation_name != 'PyPy' and platform_python_implementation != 'PyPy'
150
+ # via cffi
151
+ pydantic==2.12.5
152
+ # via
153
+ # fastapi
154
+ # gradio
155
+ # mcp
156
+ # openai
157
+ # pydantic-settings
158
+ # wavereader
159
+ pydantic-core==2.41.5
160
+ # via pydantic
161
+ pydantic-settings==2.15.0
162
+ # via mcp
163
+ pydub==0.25.1
164
+ # via gradio
165
+ pygments==2.21.0
166
+ # via rich
167
+ pyjwt==2.13.0
168
+ # via mcp
169
+ python-dateutil==2.9.0.post0
170
+ # via pandas
171
+ python-dotenv==1.2.3
172
+ # via
173
+ # pydantic-settings
174
+ # smolagents
175
+ python-multipart==0.0.32
176
+ # via
177
+ # gradio
178
+ # mcp
179
+ pytz==2026.3.post1
180
+ # via gradio
181
+ pywin32==312 ; sys_platform == 'win32'
182
+ # via mcp
183
+ pyyaml==6.0.3
184
+ # via
185
+ # gradio
186
+ # huggingface-hub
187
+ referencing==0.37.0
188
+ # via
189
+ # jsonschema
190
+ # jsonschema-specifications
191
+ requests==2.34.2
192
+ # via smolagents
193
+ rich==15.0.0
194
+ # via
195
+ # smolagents
196
+ # typer
197
+ rpds-py==2026.6.3
198
+ # via
199
+ # jsonschema
200
+ # referencing
201
+ safehttpx==0.1.7
202
+ # via gradio
203
+ semantic-version==2.10.0
204
+ # via gradio
205
+ shellingham==1.5.4
206
+ # via typer
207
+ six==1.17.0
208
+ # via python-dateutil
209
+ smolagents==1.26.0
210
+ # via wavereader
211
+ sniffio==1.3.1
212
+ # via openai
213
+ sse-starlette==3.4.11
214
+ # via mcp
215
+ starlette==1.6.0
216
+ # via
217
+ # fastapi
218
+ # gradio
219
+ # mcp
220
+ # sse-starlette
221
+ tomlkit==0.14.0
222
+ # via gradio
223
+ tqdm==4.70.0
224
+ # via huggingface-hub
225
+ truststore==0.10.4 ; sys_platform != 'emscripten'
226
+ # via
227
+ # httpcore2
228
+ # httpx2
229
+ typer==0.27.2
230
+ # via
231
+ # gradio
232
+ # hf-gradio
233
+ typing-extensions==4.16.0
234
+ # via
235
+ # anyio
236
+ # fastapi
237
+ # gradio
238
+ # gradio-client
239
+ # huggingface-hub
240
+ # mcp
241
+ # openai
242
+ # pydantic
243
+ # pydantic-core
244
+ # typing-inspection
245
+ typing-inspection==0.4.4
246
+ # via
247
+ # fastapi
248
+ # mcp
249
+ # pydantic
250
+ # pydantic-settings
251
+ tzdata==2026.3 ; sys_platform == 'emscripten' or sys_platform == 'win32'
252
+ # via pandas
253
+ urllib3==2.7.0
254
+ # via requests
255
+ uvicorn==0.52.4
256
+ # via
257
+ # gradio
258
+ # mcp
ui/app.py CHANGED
@@ -23,6 +23,8 @@ def build() -> gr.Blocks:
23
  gr.Markdown("# wave~reader", elem_classes=["hero-title"])
24
  gr.Markdown("a guide to australian surf breaks — browse, score the week, or ask the agent.",
25
  elem_classes=["hero-sub"])
 
 
26
 
27
  selected = gr.State(None)
28
  scored = gr.State(None)
@@ -41,20 +43,22 @@ def build() -> gr.Blocks:
41
  a["status_box"], a["agent_score"], a["agent_swell"],
42
  a["agent_wind"], a["spot_dd"], a["rank_df"], agent_store]
43
 
44
- # -- book: one-way filters --
 
 
45
  b["state_dd"].change(
46
  b["state_regions"], inputs=b["state_dd"], outputs=b["region_dd"],
47
  ).then(
48
  b["filter_changed"], inputs=[b["state_dd"], b["region_dd"], b["skill_dd"]],
49
- outputs=[b["map_plot"], b["break_radio"]],
50
  )
51
  b["region_dd"].change(
52
  b["filter_changed"], inputs=[b["state_dd"], b["region_dd"], b["skill_dd"]],
53
- outputs=[b["map_plot"], b["break_radio"]],
54
  )
55
  b["skill_dd"].change(
56
  b["filter_changed"], inputs=[b["state_dd"], b["region_dd"], b["skill_dd"]],
57
- outputs=[b["map_plot"], b["break_radio"]],
58
  )
59
  # -- book pick → details + climate + shared pick → swell fetch --
60
  b["break_radio"].change(
@@ -63,20 +67,25 @@ def build() -> gr.Blocks:
63
  b["pick_header"], selected],
64
  ).then(
65
  s["fetch_forecast"], inputs=[selected, s["skill_dd"]], outputs=swell_outputs,
 
66
  )
67
  # -- swell: skill change refetches; narrate streams markdown --
68
  s["skill_dd"].change(
69
  s["fetch_forecast"], inputs=[selected, s["skill_dd"]], outputs=swell_outputs,
 
70
  )
71
- s["narrate_btn"].click(s["narrate"], inputs=scored, outputs=s["report_md"])
 
72
  # -- agent: chat streams tool cards + meter + charts --
73
  a["send_btn"].click(
74
  a["chat"], inputs=[a["msg"], a["chatbot"], a["skill_dd"], a["token_box"],
75
  selected, agent_store], outputs=agent_outputs,
 
76
  )
77
  a["msg"].submit(
78
  a["chat"], inputs=[a["msg"], a["chatbot"], a["skill_dd"], a["token_box"],
79
  selected, agent_store], outputs=agent_outputs,
 
80
  )
81
  a["spot_dd"].change(
82
  a["show_spot"], inputs=[a["spot_dd"], agent_store],
 
23
  gr.Markdown("# wave~reader", elem_classes=["hero-title"])
24
  gr.Markdown("a guide to australian surf breaks — browse, score the week, or ask the agent.",
25
  elem_classes=["hero-sub"])
26
+ gr.Markdown("_MCP ready — `/gradio_api/mcp/schema` · `score_week` / `rank_region_week` / `explain_score`_",
27
+ elem_classes=["hero-sub"])
28
 
29
  selected = gr.State(None)
30
  scored = gr.State(None)
 
43
  a["status_box"], a["agent_score"], a["agent_swell"],
44
  a["agent_wind"], a["spot_dd"], a["rank_df"], agent_store]
45
 
46
+ # -- book: one-way filters (also reset details on empty result) --
47
+ _filter_outputs = [b["map_plot"], b["break_radio"], b["details_df"],
48
+ b["rose_plot"], b["audit_md"], b["pick_header"]]
49
  b["state_dd"].change(
50
  b["state_regions"], inputs=b["state_dd"], outputs=b["region_dd"],
51
  ).then(
52
  b["filter_changed"], inputs=[b["state_dd"], b["region_dd"], b["skill_dd"]],
53
+ outputs=_filter_outputs,
54
  )
55
  b["region_dd"].change(
56
  b["filter_changed"], inputs=[b["state_dd"], b["region_dd"], b["skill_dd"]],
57
+ outputs=_filter_outputs,
58
  )
59
  b["skill_dd"].change(
60
  b["filter_changed"], inputs=[b["state_dd"], b["region_dd"], b["skill_dd"]],
61
+ outputs=_filter_outputs,
62
  )
63
  # -- book pick → details + climate + shared pick → swell fetch --
64
  b["break_radio"].change(
 
67
  b["pick_header"], selected],
68
  ).then(
69
  s["fetch_forecast"], inputs=[selected, s["skill_dd"]], outputs=swell_outputs,
70
+ show_progress="full",
71
  )
72
  # -- swell: skill change refetches; narrate streams markdown --
73
  s["skill_dd"].change(
74
  s["fetch_forecast"], inputs=[selected, s["skill_dd"]], outputs=swell_outputs,
75
+ show_progress="full",
76
  )
77
+ s["narrate_btn"].click(s["narrate"], inputs=scored, outputs=s["report_md"],
78
+ show_progress="minimal")
79
  # -- agent: chat streams tool cards + meter + charts --
80
  a["send_btn"].click(
81
  a["chat"], inputs=[a["msg"], a["chatbot"], a["skill_dd"], a["token_box"],
82
  selected, agent_store], outputs=agent_outputs,
83
+ show_progress="minimal",
84
  )
85
  a["msg"].submit(
86
  a["chat"], inputs=[a["msg"], a["chatbot"], a["skill_dd"], a["token_box"],
87
  selected, agent_store], outputs=agent_outputs,
88
+ show_progress="minimal",
89
  )
90
  a["spot_dd"].change(
91
  a["show_spot"], inputs=[a["spot_dd"], agent_store],
ui/charts/climate.py CHANGED
@@ -53,12 +53,22 @@ def build_rose_fig(profile: dict | None, name: str = ""):
53
 
54
 
55
  def format_findings(findings: list[dict] | None) -> str:
56
- """Format audit findings as lo-fi chips (one line each, emoji severity)."""
57
  if not findings:
58
  return "_No climate audit yet — pick a break._"
59
  icon = {"ok": "✅", "info": "ℹ️", "warn": "⚠️", "error": "❌"}
60
- lines = []
61
- for f in findings:
62
- level = str((f or {}).get("level", "info")).lower()
63
- lines.append(f"{icon.get(level, 'ℹ️')} {(f or {}).get('message', '')}")
64
- return "\n\n".join(lines)
 
 
 
 
 
 
 
 
 
 
 
53
 
54
 
55
  def format_findings(findings: list[dict] | None) -> str:
56
+ """Format audit findings as lo-fi chips (HTML pills + emoji severity)."""
57
  if not findings:
58
  return "_No climate audit yet — pick a break._"
59
  icon = {"ok": "✅", "info": "ℹ️", "warn": "⚠️", "error": "❌"}
60
+ chips = []
61
+ for f in findings or []:
62
+ if not isinstance(f, dict):
63
+ continue
64
+ level = str(f.get("level", "info")).lower()
65
+ if level not in ("ok", "info", "warn", "error"):
66
+ level = "info"
67
+ msg = str(f.get("message", "")).replace("<", "&lt;").replace(">", "&gt;")
68
+ chips.append(
69
+ f"<span class=\"audit-chip audit-{level}\">"
70
+ f"{icon.get(level, 'ℹ️')} {msg}</span>"
71
+ )
72
+ if not chips:
73
+ return "_No climate audit yet — pick a break._"
74
+ return "<div class=\"audit-chips\">" + " ".join(chips) + "</div>"
ui/panels/book.py CHANGED
@@ -73,7 +73,12 @@ def build_book(records: list[dict], vocab: dict, selected) -> dict:
73
  fnames = [r.get("name", "?") for r in filtered]
74
  picker = gr.update(choices=fnames, value=None,
75
  label=f"Breaks (pick one for details) — {len(fnames)} spot(s)")
76
- return fig, picker
 
 
 
 
 
77
 
78
  def pick_changed(label: str | None):
79
  """Details + climate rose + audit chips for the picked break."""
 
73
  fnames = [r.get("name", "?") for r in filtered]
74
  picker = gr.update(choices=fnames, value=None,
75
  label=f"Breaks (pick one for details) — {len(fnames)} spot(s)")
76
+ if not fnames:
77
+ # Empty result: reset stale details panel (pick_changed can't fire).
78
+ return (fig, picker, _EMPTY_DETAILS, climate_chart.build_rose_fig(None),
79
+ "_No spots match these filters — loosen a filter._",
80
+ "### no break selected")
81
+ return (fig, picker, gr.skip(), gr.skip(), gr.skip(), gr.skip())
82
 
83
  def pick_changed(label: str | None):
84
  """Details + climate rose + audit chips for the picked break."""
ui/theme.py CHANGED
@@ -55,6 +55,19 @@ button.primary:hover, button.lg.primary:hover {
55
  color: #ffffff !important;
56
  }
57
  .break-list { max-height: 210px; overflow-y: auto; border: 1px solid #0b2c5c; border-radius: 8px; padding: 4px; background: #eef6fd !important; }
 
 
 
 
 
 
 
 
 
 
 
 
 
58
  /* selected break dot -> dark blue (radio circle fill + border) */
59
  .break-list input[type="radio"]:checked,
60
  .break-list input[type="checkbox"]:checked {
 
55
  color: #ffffff !important;
56
  }
57
  .break-list { max-height: 210px; overflow-y: auto; border: 1px solid #0b2c5c; border-radius: 8px; padding: 4px; background: #eef6fd !important; }
58
+ /* climate audit chips: lo-fi pills per severity */
59
+ .audit-chips { display: flex; flex-wrap: wrap; gap: 6px; margin: 4px 0; }
60
+ .audit-chip { display: inline-block; border: 2px solid #0b2c5c; border-radius: 999px; padding: 3px 10px; font-size: 0.8em; background: #ffffff; color: #0b2c5c; }
61
+ .audit-ok { background: #e6f4ea !important; }
62
+ .audit-info { background: #eef6fd !important; }
63
+ .audit-warn { background: #fef7e0 !important; }
64
+ .audit-error { background: #fce8e6 !important; }
65
+ /* mobile: stack columns, shrink map */
66
+ @media (max-width: 700px) {
67
+ .gradio-container .gr-row { flex-wrap: wrap !important; }
68
+ .gradio-container .gr-column { min-width: 100% !important; }
69
+ .hero-title { font-size: 1.6em !important; }
70
+ }
71
  /* selected break dot -> dark blue (radio circle fill + border) */
72
  .break-list input[type="radio"]:checked,
73
  .break-list input[type="checkbox"]:checked {
wavereader/openmeteo.py CHANGED
@@ -222,7 +222,7 @@ def get_forecast(lat: float, lon: float, days: int = 7) -> dict:
222
 
223
  try:
224
  merged = _fetch_open_meteo(rlat, rlon, days)
225
- except (httpx.HTTPError, KeyError, ValueError):
226
  if cached is not None:
227
  return cached[0]
228
  raise
 
222
 
223
  try:
224
  merged = _fetch_open_meteo(rlat, rlon, days)
225
+ except (httpx.HTTPError, KeyError, ValueError, TypeError, AttributeError):
226
  if cached is not None:
227
  return cached[0]
228
  raise
wavereader/scoring.py CHANGED
@@ -149,6 +149,15 @@ def _normalize_wind_type(wind_type: str | None) -> str:
149
  return "light/variable"
150
 
151
 
 
 
 
 
 
 
 
 
 
152
  def enriched_to_scoring_spot(break_: dict) -> dict:
153
  """Convert one enriched break record to the scoring spot shape.
154
 
@@ -158,14 +167,16 @@ def enriched_to_scoring_spot(break_: dict) -> dict:
158
  swell = break_.get("idealSwell", {}) or {}
159
  size = swell.get("sizeRangeFt", {}) or {}
160
  wind = break_.get("idealWind", {}) or {}
 
 
161
  return {
162
  "name": break_.get("name", ""),
163
  "region": break_.get("region", ""),
164
  "break_type": break_.get("breakType", ""),
165
  "ideal_swell": {
166
  "direction": _join_direction(swell.get("direction")),
167
- "size_ft_min": float(size.get("min", 0.0)),
168
- "size_ft_max": float(size.get("max", 0.0)),
169
  },
170
  "ideal_wind": {
171
  "direction": _join_direction(wind.get("direction")),
@@ -404,6 +415,10 @@ def _daylight_windows(daily: dict | None) -> dict[str, tuple[str, str]]:
404
  continue
405
  if not (isinstance(rise, str) and isinstance(set_, str)):
406
  continue
 
 
 
 
407
  # ISO local "YYYY-MM-DDTHH:MM" — compare the HH:MM slice.
408
  out[str(day)[:10]] = (rise[11:16], set_[11:16])
409
  return out
@@ -478,8 +493,9 @@ def _pick_height(row: dict) -> float | None:
478
  """Prefer the swell component; fall back to the generic aggregate."""
479
  for key in ("swell_wave_height", "wave_height"):
480
  v = row.get(key)
481
- if v is not None:
482
- return v
 
483
  return None
484
 
485
 
@@ -487,8 +503,9 @@ def _pick_direction(row: dict) -> float | None:
487
  """Prefer the swell direction; fall back to the generic aggregate."""
488
  for key in ("swell_wave_direction", "wave_direction"):
489
  v = row.get(key)
490
- if v is not None:
491
- return v
 
492
  return None
493
 
494
 
@@ -496,8 +513,9 @@ def _pick_period(row: dict) -> float | None:
496
  """Prefer the swell period; fall back to the generic aggregate."""
497
  for key in ("swell_wave_period", "wave_period"):
498
  v = row.get(key)
499
- if v is not None:
500
- return v
 
501
  return None
502
 
503
 
@@ -512,42 +530,47 @@ def score_week(
512
  hour. Each returned hour carries its ``"time"``. Hours missing any
513
  required field are skipped.
514
  """
515
- rows = forecast.get("hourly", [])
 
 
516
  windows = _daylight_windows(forecast.get("daily")) if daylight_only else {}
517
  results = []
518
 
519
  for row in rows:
520
- if daylight_only and not is_daylight(str(row.get("time", "")), windows):
 
 
 
 
 
521
  continue
522
  wave_height = _pick_height(row)
523
  wave_period = _pick_period(row)
524
- wind_speed = row.get("wind_speed_10m")
525
- wind_dir = row.get("wind_direction_10m")
526
 
527
  if None in (wave_height, wave_period, wind_speed, wind_dir):
528
  continue
529
 
530
- gust_raw = row.get("wind_gusts_10m")
 
 
 
531
  try:
532
- gust_kt = float(gust_raw) * 0.539957 if gust_raw is not None else None
533
- except (TypeError, ValueError):
534
- gust_kt = None
535
-
536
- scored = score_hour(
537
- wave_height_m=float(wave_height),
538
- wave_period_s=float(wave_period),
539
- wind_speed_kt=float(wind_speed) * 0.539957, # km/h to knots conversion
540
- wind_direction_deg=float(wind_dir),
541
- spot=spot,
542
- wave_direction_deg=(
543
- float(_pick_direction(row))
544
- if _pick_direction(row) is not None
545
- else None
546
- ),
547
- skill_level=skill_level,
548
- gust_kt=gust_kt,
549
- )
550
- scored["time"] = row["time"]
551
  results.append(scored)
552
 
553
  return results
@@ -575,6 +598,8 @@ def daily_summary(scored_hours: list[dict[str, Any]]) -> list[dict[str, Any]]:
575
  """
576
  by_date: dict[str, list[float]] = {}
577
  for h in scored_hours:
 
 
578
  t = h.get("time")
579
  if not isinstance(t, str):
580
  continue
@@ -582,6 +607,8 @@ def daily_summary(scored_hours: list[dict[str, Any]]) -> list[dict[str, Any]]:
582
  score = float(h["score"])
583
  except (KeyError, TypeError, ValueError):
584
  continue
 
 
585
  by_date.setdefault(t[:10], []).append(score)
586
 
587
  out = []
 
149
  return "light/variable"
150
 
151
 
152
+ def _safe_float(value) -> float | None:
153
+ """Coerce to finite float, else None (rejects None/NaN/garbage)."""
154
+ try:
155
+ f = float(value)
156
+ except (TypeError, ValueError):
157
+ return None
158
+ return f if math.isfinite(f) else None
159
+
160
+
161
  def enriched_to_scoring_spot(break_: dict) -> dict:
162
  """Convert one enriched break record to the scoring spot shape.
163
 
 
167
  swell = break_.get("idealSwell", {}) or {}
168
  size = swell.get("sizeRangeFt", {}) or {}
169
  wind = break_.get("idealWind", {}) or {}
170
+ size_min = _safe_float(size.get("min", 0.0))
171
+ size_max = _safe_float(size.get("max", 0.0))
172
  return {
173
  "name": break_.get("name", ""),
174
  "region": break_.get("region", ""),
175
  "break_type": break_.get("breakType", ""),
176
  "ideal_swell": {
177
  "direction": _join_direction(swell.get("direction")),
178
+ "size_ft_min": size_min if size_min is not None else 0.0,
179
+ "size_ft_max": size_max if size_max is not None else 0.0,
180
  },
181
  "ideal_wind": {
182
  "direction": _join_direction(wind.get("direction")),
 
415
  continue
416
  if not (isinstance(rise, str) and isinstance(set_, str)):
417
  continue
418
+ # ISO local "YYYY-MM-DDTHH:MM" — require full length, else skip
419
+ # (a truncated sun string must not nuke the whole date).
420
+ if len(rise) < 16 or len(set_) < 16:
421
+ continue
422
  # ISO local "YYYY-MM-DDTHH:MM" — compare the HH:MM slice.
423
  out[str(day)[:10]] = (rise[11:16], set_[11:16])
424
  return out
 
493
  """Prefer the swell component; fall back to the generic aggregate."""
494
  for key in ("swell_wave_height", "wave_height"):
495
  v = row.get(key)
496
+ f = _safe_float(v) if v is not None else None
497
+ if f is not None:
498
+ return f
499
  return None
500
 
501
 
 
503
  """Prefer the swell direction; fall back to the generic aggregate."""
504
  for key in ("swell_wave_direction", "wave_direction"):
505
  v = row.get(key)
506
+ f = _safe_float(v) if v is not None else None
507
+ if f is not None:
508
+ return f
509
  return None
510
 
511
 
 
513
  """Prefer the swell period; fall back to the generic aggregate."""
514
  for key in ("swell_wave_period", "wave_period"):
515
  v = row.get(key)
516
+ f = _safe_float(v) if v is not None else None
517
+ if f is not None:
518
+ return f
519
  return None
520
 
521
 
 
530
  hour. Each returned hour carries its ``"time"``. Hours missing any
531
  required field are skipped.
532
  """
533
+ rows = forecast.get("hourly", []) if isinstance(forecast, dict) else []
534
+ if not isinstance(rows, list):
535
+ return []
536
  windows = _daylight_windows(forecast.get("daily")) if daylight_only else {}
537
  results = []
538
 
539
  for row in rows:
540
+ if not isinstance(row, dict):
541
+ continue
542
+ t = row.get("time")
543
+ if not isinstance(t, str) or not t:
544
+ continue
545
+ if daylight_only and not is_daylight(t, windows):
546
  continue
547
  wave_height = _pick_height(row)
548
  wave_period = _pick_period(row)
549
+ wind_speed = _safe_float(row.get("wind_speed_10m"))
550
+ wind_dir = _safe_float(row.get("wind_direction_10m"))
551
 
552
  if None in (wave_height, wave_period, wind_speed, wind_dir):
553
  continue
554
 
555
+ gust_kt = _safe_float(row.get("wind_gusts_10m"))
556
+ if gust_kt is not None:
557
+ gust_kt = gust_kt * 0.539957
558
+
559
  try:
560
+ scored = score_hour(
561
+ wave_height_m=float(wave_height),
562
+ wave_period_s=float(wave_period),
563
+ wind_speed_kt=float(wind_speed) * 0.539957, # km/h to knots conversion
564
+ wind_direction_deg=float(wind_dir),
565
+ spot=spot,
566
+ wave_direction_deg=_pick_direction(row),
567
+ skill_level=skill_level,
568
+ gust_kt=gust_kt,
569
+ )
570
+ except (ValueError, TypeError, KeyError):
571
+ # One bad spot field or hour must not abort the whole week.
572
+ continue
573
+ scored["time"] = t
 
 
 
 
 
574
  results.append(scored)
575
 
576
  return results
 
598
  """
599
  by_date: dict[str, list[float]] = {}
600
  for h in scored_hours:
601
+ if not isinstance(h, dict):
602
+ continue
603
  t = h.get("time")
604
  if not isinstance(t, str):
605
  continue
 
607
  score = float(h["score"])
608
  except (KeyError, TypeError, ValueError):
609
  continue
610
+ if not math.isfinite(score):
611
+ continue
612
  by_date.setdefault(t[:10], []).append(score)
613
 
614
  out = []
wavereader/tools.py CHANGED
@@ -57,6 +57,11 @@ except ImportError:
57
  _HAS_BREAKS = False
58
  _core_resolve_break = None # type: ignore
59
 
 
 
 
 
 
60
  try: # Worker A: wavereader/openmeteo.py :: get_forecast(lat, lon, days)
61
  from wavereader.openmeteo import get_forecast as _core_get_forecast # type: ignore
62
  _HAS_FORECAST = True
@@ -200,17 +205,16 @@ def _coords(break_: dict) -> tuple[float | None, float | None]:
200
 
201
 
202
  def _real_scored(break_: dict, skill_level: str) -> list[dict]:
203
- """STUB(merge): fetch + score via Worker A; raises on any failure.
204
-
205
- Was the fake-data path before Worker A landed; now the primary path.
206
- """
207
  if not (_HAS_SCORING and _HAS_FORECAST and _core_score_week is not None and _core_get_forecast is not None):
208
  raise RuntimeError("scoring/forecast backends not available")
 
 
209
  lat, lng = _coords(break_)
210
  if lat is None or lng is None:
211
  raise RuntimeError(f"Spot '{break_.get('name')}' has no coordinates")
212
  forecast = _core_get_forecast(lat, lng, 7)
213
- spot = dict(break_)
214
  return _core_score_week(forecast, spot, skill_level)
215
 
216
 
@@ -257,13 +261,24 @@ def _slim_hour(row: dict) -> dict:
257
  )}
258
 
259
 
 
 
 
 
 
 
 
 
 
260
  def _daily_best(scored: list[dict]) -> list[dict]:
261
  best_by_date: dict[str, dict] = {}
262
  for row in scored or []:
 
 
263
  date = str(row.get("time", ""))[:10]
264
  if not date:
265
  continue
266
- if date not in best_by_date or float(row.get("score", 0) or 0) > float(best_by_date[date].get("score", 0) or 0):
267
  best_by_date[date] = row
268
  return [
269
  {"date": d, **_slim_hour(best_by_date[d])}
 
57
  _HAS_BREAKS = False
58
  _core_resolve_break = None # type: ignore
59
 
60
+ try: # Worker A: wavereader/scoring.py :: enriched_to_scoring_spot
61
+ from wavereader.scoring import enriched_to_scoring_spot as _core_to_spot # type: ignore
62
+ except ImportError:
63
+ _core_to_spot = None # type: ignore
64
+
65
  try: # Worker A: wavereader/openmeteo.py :: get_forecast(lat, lon, days)
66
  from wavereader.openmeteo import get_forecast as _core_get_forecast # type: ignore
67
  _HAS_FORECAST = True
 
205
 
206
 
207
  def _real_scored(break_: dict, skill_level: str) -> list[dict]:
208
+ """Fetch + score via Worker A; raises on any failure (no fake fallback)."""
 
 
 
209
  if not (_HAS_SCORING and _HAS_FORECAST and _core_score_week is not None and _core_get_forecast is not None):
210
  raise RuntimeError("scoring/forecast backends not available")
211
+ if _core_to_spot is None:
212
+ raise RuntimeError("scoring spot adapter not available")
213
  lat, lng = _coords(break_)
214
  if lat is None or lng is None:
215
  raise RuntimeError(f"Spot '{break_.get('name')}' has no coordinates")
216
  forecast = _core_get_forecast(lat, lng, 7)
217
+ spot = _core_to_spot(break_)
218
  return _core_score_week(forecast, spot, skill_level)
219
 
220
 
 
261
  )}
262
 
263
 
264
+ def _safe_score(value) -> float:
265
+ try:
266
+ f = float(value or 0)
267
+ except (TypeError, ValueError):
268
+ return 0.0
269
+ import math as _math
270
+ return f if _math.isfinite(f) else 0.0
271
+
272
+
273
  def _daily_best(scored: list[dict]) -> list[dict]:
274
  best_by_date: dict[str, dict] = {}
275
  for row in scored or []:
276
+ if not isinstance(row, dict):
277
+ continue
278
  date = str(row.get("time", ""))[:10]
279
  if not date:
280
  continue
281
+ if date not in best_by_date or _safe_score(row.get("score")) > _safe_score(best_by_date[date].get("score")):
282
  best_by_date[date] = row
283
  return [
284
  {"date": d, **_slim_hour(best_by_date[d])}