stofs-mcp 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,42 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+ *.egg-info/
6
+ *.egg
7
+ dist/
8
+ build/
9
+ *.whl
10
+
11
+ # Virtual environments
12
+ .venv/
13
+ venv/
14
+ env/
15
+
16
+ # Testing
17
+ .pytest_cache/
18
+ .coverage
19
+ htmlcov/
20
+ .tox/
21
+
22
+ # IDE
23
+ .idea/
24
+ .vscode/
25
+ *.swp
26
+ *.swo
27
+ *~
28
+
29
+ # OS
30
+ .DS_Store
31
+ Thumbs.db
32
+
33
+ # Linting
34
+ .ruff_cache/
35
+ .mypy_cache/
36
+
37
+ # Lock files (each server manages its own)
38
+ uv.lock
39
+
40
+ # Environment
41
+ .env
42
+ .env.local
@@ -0,0 +1,614 @@
1
+ # CLAUDE.md — Add OPeNDAP Gridded Point Extraction to stofs-mcp
2
+
3
+ ## Overview
4
+
5
+ Add a new tool `stofs_get_gridded_forecast` and supporting OPeNDAP client code to the existing `stofs-mcp` server. This enables forecast queries at **any arbitrary lat/lon** by remotely slicing STOFS regular-grid data via NOMADS OPeNDAP — no file download required.
6
+
7
+ This complements the existing station-based tools. The station tools use pre-extracted point files (~385 fixed locations); this new tool uses the interpolated regular grids served via OPeNDAP to reach any coastal point.
8
+
9
+ **Do NOT modify existing tools.** This is purely additive.
10
+
11
+ ---
12
+
13
+ ## Background: How OPeNDAP Works Here
14
+
15
+ STOFS produces two kinds of gridded output:
16
+ 1. **Native unstructured mesh** (ADCIRC triangles, 12.8M nodes) — huge files, OPeNDAP cannot do spatial queries on these
17
+ 2. **Regular-grid GRIB2 regional subsets** — interpolated to structured lat/lon grids, served via NOMADS OPeNDAP
18
+
19
+ NOMADS OPeNDAP endpoints expose the regular-grid products as remote datasets that xarray can open. When you do `ds.sel(lat=40.7, lon=-74.0, method="nearest")`, only the requested slice is transferred over the network — not the entire grid. This is the key advantage.
20
+
21
+ ### OPeNDAP URL Pattern
22
+
23
+ ```
24
+ https://nomads.ncep.noaa.gov/dods/stofs_2d_glo/stofs_2d_glo{YYYYMMDD}/stofs_2d_glo_{CC}z
25
+ ```
26
+
27
+ Where `CC` is the cycle hour (00, 06, 12, 18).
28
+
29
+ Example: `https://nomads.ncep.noaa.gov/dods/stofs_2d_glo/stofs_2d_glo20260219/stofs_2d_glo_00z`
30
+
31
+ For STOFS-3D-Atlantic:
32
+ ```
33
+ https://nomads.ncep.noaa.gov/dods/stofs_3d_atl/stofs_3d_atl{YYYYMMDD}/stofs_3d_atl_12z
34
+ ```
35
+
36
+ ### What the OPeNDAP Dataset Contains
37
+
38
+ The OPeNDAP endpoint serves the data that was interpolated from the native mesh onto regular grids. The dataset has structured dimensions:
39
+
40
+ ```
41
+ Dimensions:
42
+ time: ~181 (hourly, 0-180 hours for 2D-Global)
43
+ lat: varies by region
44
+ lon: varies by region
45
+
46
+ Coordinates:
47
+ time (time) — datetime or hours since reference
48
+ lat (lat) — regularly spaced latitude values
49
+ lon (lon) — regularly spaced longitude values
50
+
51
+ Data variables:
52
+ etsurgetsrg — storm surge (m)
53
+ etwlswlc — combined water level (m)
54
+ etrtpcrlc — tidal prediction (m)
55
+ ```
56
+
57
+ **IMPORTANT:** The variable names on OPeNDAP are different from the native NetCDF files. They use abbreviated GRIB2-style names, not `zeta`. You MUST inspect the actual dataset to confirm the variable names. The names above are based on documented NOMADS conventions for STOFS but may vary. The implementation must handle this discovery dynamically.
58
+
59
+ ### Regional Grid Coverage
60
+
61
+ The OPeNDAP dataset covers multiple regions at different resolutions. The data is served as one combined dataset — use lat/lon subsetting to query specific points.
62
+
63
+ | Region | Approx Resolution | Lat Range | Lon Range |
64
+ |--------|------------------|-----------|-----------|
65
+ | conus.east | 2.5 km (~0.025°) | ~5°N to ~47°N | ~-100°W to ~-50°W |
66
+ | conus.west | 2.5 km (~0.025°) | ~20°N to ~55°N | ~-135°W to ~-110°W |
67
+ | alaska | 6 km (~0.06°) | ~45°N to ~75°N | ~180°W to ~-120°W |
68
+ | hawaii | 2.5 km (~0.025°) | ~15°N to ~25°N | ~-165°W to ~-150°W |
69
+ | puertori | 1.25 km (~0.0125°) | ~15°N to ~21°N | ~-70°W to ~-60°W |
70
+ | guam | 2.5 km (~0.025°) | ~10°N to ~18°N | ~140°E to ~150°E |
71
+
72
+ ---
73
+
74
+ ## What to Implement
75
+
76
+ ### 1. Add `xarray` dependency
77
+
78
+ In `pyproject.toml`, add `xarray>=2024.1.0` to the `dependencies` list:
79
+
80
+ ```toml
81
+ dependencies = [
82
+ "mcp[cli]>=1.0.0",
83
+ "httpx>=0.27.0",
84
+ "pydantic>=2.0.0",
85
+ "netCDF4>=1.7.0",
86
+ "numpy>=1.26.0",
87
+ "xarray>=2024.1.0",
88
+ ]
89
+ ```
90
+
91
+ xarray uses netCDF4 as its engine for OPeNDAP access — no additional dependency needed.
92
+
93
+ ### 2. Add OPeNDAP methods to `client.py`
94
+
95
+ Add these constants and methods to the existing `STOFSClient` class:
96
+
97
+ ```python
98
+ OPENDAP_BASE_2D = "https://nomads.ncep.noaa.gov/dods/stofs_2d_glo"
99
+ OPENDAP_BASE_3D = "https://nomads.ncep.noaa.gov/dods/stofs_3d_atl"
100
+ ```
101
+
102
+ Add a method to build the OPeNDAP URL:
103
+
104
+ ```python
105
+ def build_opendap_url(self, model: str, date: str, cycle: str) -> str:
106
+ """Build NOMADS OPeNDAP URL for STOFS regular-grid data.
107
+
108
+ Args:
109
+ model: '2d_global' or '3d_atlantic'.
110
+ date: YYYYMMDD format.
111
+ cycle: '00', '06', '12', '18'.
112
+
113
+ Returns:
114
+ OPeNDAP URL string.
115
+ """
116
+ if model == "2d_global":
117
+ return f"{OPENDAP_BASE_2D}/stofs_2d_glo{date}/stofs_2d_glo_{cycle}z"
118
+ elif model == "3d_atlantic":
119
+ return f"{OPENDAP_BASE_3D}/stofs_3d_atl{date}/stofs_3d_atl_{cycle}z"
120
+ else:
121
+ raise ValueError(f"Unknown model '{model}'.")
122
+ ```
123
+
124
+ Add a method to check if the OPeNDAP endpoint is reachable:
125
+
126
+ ```python
127
+ async def check_opendap_available(self, url: str) -> bool:
128
+ """Check if a NOMADS OPeNDAP endpoint is reachable.
129
+
130
+ Tests by fetching the .das (Dataset Attribute Structure) — a small text response.
131
+ """
132
+ client = await self._get_client()
133
+ try:
134
+ response = await client.get(f"{url}.das", timeout=15.0)
135
+ return response.status_code == 200
136
+ except Exception:
137
+ return False
138
+ ```
139
+
140
+ ### 3. Add OPeNDAP extraction utility in `utils.py`
141
+
142
+ Add a new function. This is the core logic — it opens the remote dataset, extracts a point, and returns the time series. **This function uses blocking I/O** (xarray's OPeNDAP access is synchronous) so it must be called carefully (see tool implementation below).
143
+
144
+ ```python
145
+ def extract_point_from_opendap(
146
+ opendap_url: str,
147
+ latitude: float,
148
+ longitude: float,
149
+ variable: str | None = None,
150
+ ) -> dict[str, Any]:
151
+ """Extract a time series at a single lat/lon from a NOMADS OPeNDAP dataset.
152
+
153
+ Opens the remote dataset with xarray, selects the nearest grid point,
154
+ and returns the time series. Only the requested slice is downloaded.
155
+
156
+ Args:
157
+ opendap_url: Full OPeNDAP URL (e.g., https://nomads.ncep.noaa.gov/dods/...).
158
+ latitude: Target latitude in decimal degrees.
159
+ longitude: Target longitude in decimal degrees.
160
+ variable: Specific variable name to extract. If None, auto-detect
161
+ the water level variable.
162
+
163
+ Returns:
164
+ Dict with keys:
165
+ - 'times': list of ISO 8601 datetime strings
166
+ - 'values': list of water level values (m)
167
+ - 'actual_lat': float — latitude of the nearest grid point
168
+ - 'actual_lon': float — longitude of the nearest grid point
169
+ - 'variable': str — the variable name used
170
+ - 'n_times': int — number of time steps
171
+ - 'grid_resolution_deg': float — approximate grid spacing
172
+
173
+ Raises:
174
+ RuntimeError: If the OPeNDAP endpoint is unreachable or data cannot be read.
175
+ ValueError: If no suitable water level variable is found.
176
+ """
177
+ import xarray as xr
178
+ import numpy as np
179
+
180
+ try:
181
+ ds = xr.open_dataset(opendap_url, engine="netcdf4")
182
+ except Exception as e:
183
+ raise RuntimeError(
184
+ f"Cannot open OPeNDAP dataset at {opendap_url}. "
185
+ f"NOMADS may be temporarily unavailable. Error: {e}"
186
+ )
187
+
188
+ try:
189
+ # --- Auto-detect water level variable if not specified ---
190
+ if variable is None:
191
+ # Known STOFS OPeNDAP variable names (check in order of preference)
192
+ candidates = [
193
+ "etwlswlc", # combined water level
194
+ "etsurgetsrg", # storm surge
195
+ "etrtpcrlc", # tidal prediction
196
+ "zeta", # sometimes used
197
+ "water_level",
198
+ ]
199
+ for name in candidates:
200
+ if name in ds.data_vars:
201
+ variable = name
202
+ break
203
+
204
+ if variable is None:
205
+ available = list(ds.data_vars)
206
+ ds.close()
207
+ raise ValueError(
208
+ f"No known water level variable found in OPeNDAP dataset. "
209
+ f"Available variables: {available}. "
210
+ f"Specify the variable name explicitly."
211
+ )
212
+
213
+ if variable not in ds.data_vars:
214
+ available = list(ds.data_vars)
215
+ ds.close()
216
+ raise ValueError(
217
+ f"Variable '{variable}' not found. Available: {available}"
218
+ )
219
+
220
+ # --- Select nearest grid point ---
221
+ point = ds[variable].sel(lat=latitude, lon=longitude, method="nearest")
222
+
223
+ actual_lat = float(point.lat.values)
224
+ actual_lon = float(point.lon.values)
225
+
226
+ # --- Extract time series ---
227
+ values_raw = point.values # shape: (time,)
228
+
229
+ # Handle time coordinate
230
+ times_raw = point.time.values if "time" in point.coords else point.coords[list(point.dims)[0]].values
231
+
232
+ # Convert numpy datetime64 to ISO strings
233
+ times_out = []
234
+ for t in times_raw:
235
+ if hasattr(t, "isoformat"):
236
+ times_out.append(t.isoformat()[:16].replace("T", " "))
237
+ else:
238
+ # numpy datetime64
239
+ ts = (t - np.datetime64("1970-01-01T00:00:00")) / np.timedelta64(1, "s")
240
+ from datetime import datetime, timezone
241
+ dt = datetime.fromtimestamp(float(ts), tz=timezone.utc)
242
+ times_out.append(dt.strftime("%Y-%m-%d %H:%M"))
243
+
244
+ # Filter NaN / fill values
245
+ values_out = []
246
+ times_filtered = []
247
+ for t_str, v in zip(times_out, values_raw.tolist()):
248
+ if v is not None and not np.isnan(v) and abs(v) < 1e10:
249
+ times_filtered.append(t_str)
250
+ values_out.append(round(float(v), 4))
251
+
252
+ # Estimate grid resolution
253
+ if len(ds.lat) > 1:
254
+ grid_res = abs(float(ds.lat[1] - ds.lat[0]))
255
+ else:
256
+ grid_res = 0.0
257
+
258
+ return {
259
+ "times": times_filtered,
260
+ "values": values_out,
261
+ "actual_lat": round(actual_lat, 4),
262
+ "actual_lon": round(actual_lon, 4),
263
+ "variable": variable,
264
+ "n_times": len(times_filtered),
265
+ "grid_resolution_deg": round(grid_res, 4),
266
+ }
267
+
268
+ finally:
269
+ ds.close()
270
+ ```
271
+
272
+ ### 4. Add the new tool in `tools/forecast.py`
273
+
274
+ Add `stofs_get_gridded_forecast` to the existing `tools/forecast.py` file. This tool wraps the OPeNDAP extraction with proper async handling.
275
+
276
+ **CRITICAL: xarray OPeNDAP access is synchronous (blocking I/O).** You must wrap the call in `asyncio.to_thread()` so it doesn't block the MCP server's event loop.
277
+
278
+ ```python
279
+ @mcp.tool(
280
+ annotations=ToolAnnotations(
281
+ readOnlyHint=True,
282
+ destructiveHint=False,
283
+ idempotentHint=True,
284
+ openWorldHint=True,
285
+ )
286
+ )
287
+ async def stofs_get_gridded_forecast(
288
+ ctx: Context,
289
+ latitude: float,
290
+ longitude: float,
291
+ model: STOFSModel = STOFSModel.GLOBAL_2D,
292
+ variable: str | None = None,
293
+ cycle_date: str | None = None,
294
+ cycle_hour: str | None = None,
295
+ response_format: str = "markdown",
296
+ ) -> str:
297
+ """Get STOFS forecast at any lat/lon from the regular gridded product via OPeNDAP.
298
+
299
+ Unlike stofs_get_station_forecast (limited to ~385 fixed stations), this tool
300
+ queries the STOFS regular-grid product at any coastal point. Data is fetched
301
+ remotely from NOMADS — only the requested grid cell is downloaded.
302
+
303
+ Coverage: US East Coast, West Coast, Gulf, Alaska, Hawaii, Puerto Rico, Guam.
304
+ Resolution: ~2.5 km (conus), ~1.25 km (Puerto Rico), ~6 km (Alaska).
305
+
306
+ Note: Uses NOMADS OPeNDAP which has a ~2-day rolling window and can be
307
+ intermittently slow or unavailable. If this fails, use stofs_get_point_forecast
308
+ (station-based) as a fallback.
309
+
310
+ Args:
311
+ latitude: Target latitude in decimal degrees.
312
+ longitude: Target longitude in decimal degrees.
313
+ model: '2d_global' or '3d_atlantic'.
314
+ variable: OPeNDAP variable name (auto-detected if None).
315
+ Common: 'etwlswlc' (combined WL), 'etsurgetsrg' (surge).
316
+ cycle_date: Date in YYYY-MM-DD format. Default: latest available.
317
+ cycle_hour: Cycle hour '00', '06', '12', '18'. Default: latest.
318
+ response_format: 'markdown' or 'json'.
319
+ """
320
+ import asyncio
321
+
322
+ try:
323
+ client = _get_client(ctx)
324
+
325
+ # Resolve cycle
326
+ cycle = await _resolve_cycle(client, model.value, cycle_date, cycle_hour)
327
+ if not cycle:
328
+ return (
329
+ "No STOFS cycles found. Use stofs_list_cycles to check available data."
330
+ )
331
+ date_str, hour_str = cycle
332
+
333
+ # Build OPeNDAP URL
334
+ opendap_url = client.build_opendap_url(model.value, date_str, hour_str)
335
+
336
+ # Check availability first (fast HTTP check)
337
+ available = await client.check_opendap_available(opendap_url)
338
+ if not available:
339
+ return (
340
+ f"NOMADS OPeNDAP endpoint is not available for cycle "
341
+ f"{date_str} {hour_str}z.\n\n"
342
+ "NOMADS keeps only a ~2-day rolling window and can be intermittently "
343
+ "down. Alternatives:\n"
344
+ "- Try a different cycle with stofs_list_cycles\n"
345
+ "- Use stofs_get_point_forecast (station-based, uses AWS S3 which "
346
+ "is more reliable)"
347
+ )
348
+
349
+ # Run the blocking xarray OPeNDAP call in a thread
350
+ from ..utils import extract_point_from_opendap
351
+
352
+ data = await asyncio.to_thread(
353
+ extract_point_from_opendap,
354
+ opendap_url,
355
+ latitude,
356
+ longitude,
357
+ variable,
358
+ )
359
+
360
+ if not data["times"]:
361
+ return (
362
+ f"No valid data at ({latitude:.4f}, {longitude:.4f}). "
363
+ "The point may be over land or outside the model domain. "
364
+ "Try a location closer to the coast."
365
+ )
366
+
367
+ datum = MODEL_DATUMS.get(model.value, "unknown")
368
+ model_label = (
369
+ "STOFS-2D-Global" if model.value == "2d_global"
370
+ else "STOFS-3D-Atlantic"
371
+ )
372
+
373
+ dist_note = ""
374
+ # Approximate distance from requested point to actual grid cell center
375
+ from ..utils import _haversine
376
+ snap_dist = _haversine(
377
+ latitude, longitude, data["actual_lat"], data["actual_lon"]
378
+ )
379
+ if snap_dist > 0.1:
380
+ dist_note = f"Grid snap distance: {snap_dist:.1f} km"
381
+
382
+ if response_format == "json":
383
+ return json.dumps({
384
+ "query_lat": latitude,
385
+ "query_lon": longitude,
386
+ "actual_lat": data["actual_lat"],
387
+ "actual_lon": data["actual_lon"],
388
+ "grid_resolution_deg": data["grid_resolution_deg"],
389
+ "snap_distance_km": round(snap_dist, 2),
390
+ "model": model.value,
391
+ "variable": data["variable"],
392
+ "cycle_date": date_str,
393
+ "cycle_hour": hour_str,
394
+ "datum": datum,
395
+ "source": "NOMADS OPeNDAP (regular grid)",
396
+ "n_points": data["n_times"],
397
+ "times": data["times"],
398
+ "values": data["values"],
399
+ }, indent=2)
400
+
401
+ metadata = [
402
+ f"Model: {model_label} (regular grid via OPeNDAP)",
403
+ f"Variable: {data['variable']}",
404
+ f"Cycle: {date_str[:4]}-{date_str[4:6]}-{date_str[6:]} {hour_str}z",
405
+ f"Datum: {datum}",
406
+ f"Grid point: ({data['actual_lat']}, {data['actual_lon']})",
407
+ f"Grid resolution: ~{data['grid_resolution_deg']}°",
408
+ ]
409
+ if dist_note:
410
+ metadata.append(dist_note)
411
+
412
+ return format_timeseries_table(
413
+ times=data["times"],
414
+ values=data["values"],
415
+ title=f"{model_label} Gridded Forecast — ({latitude:.4f}°, {longitude:.4f}°)",
416
+ metadata_lines=metadata,
417
+ source="NOAA STOFS via NOMADS OPeNDAP",
418
+ )
419
+
420
+ except Exception as e:
421
+ return handle_stofs_error(e, model.value)
422
+ ```
423
+
424
+ ### 5. Update `stofs_get_point_forecast` to mention the gridded alternative
425
+
426
+ In the existing `stofs_get_point_forecast` tool, update the "no station found" error message to mention the gridded tool as an alternative. Find the block that returns the "No STOFS station found" message and add a line:
427
+
428
+ ```python
429
+ "- Use stofs_get_gridded_forecast for any lat/lon (uses OPeNDAP regular grid)\n"
430
+ ```
431
+
432
+ ### 6. Update `stofs_get_system_info`
433
+
434
+ In the `SPECS` dict for each model, add a line about OPeNDAP availability:
435
+
436
+ ```python
437
+ "opendap": "https://nomads.ncep.noaa.gov/dods/stofs_2d_glo/ (regular grid, ~2-day window)",
438
+ ```
439
+
440
+ And for 3D:
441
+ ```python
442
+ "opendap": "https://nomads.ncep.noaa.gov/dods/stofs_3d_atl/ (regular grid, ~2-day window)",
443
+ ```
444
+
445
+ ---
446
+
447
+ ## What NOT to Change
448
+
449
+ - Do NOT modify `stofs_get_station_forecast` — it stays S3 + NetCDF based
450
+ - Do NOT modify `stofs_compare_with_observations` — it uses station files for validation
451
+ - Do NOT modify `stofs_get_max_water_level` — station-based is correct for this
452
+ - Do NOT remove any existing tools or change their behavior
453
+ - Do NOT add xarray to any of the station-based code paths
454
+
455
+ ---
456
+
457
+ ## Add Tests
458
+
459
+ ### Unit test in `tests/test_utils.py`
460
+
461
+ Add a test for the OPeNDAP URL builder:
462
+
463
+ ```python
464
+ class TestBuildOpendapUrl:
465
+ def setup_method(self):
466
+ self.client = STOFSClient()
467
+
468
+ def test_2d_global(self):
469
+ url = self.client.build_opendap_url("2d_global", "20260219", "12")
470
+ assert "nomads.ncep.noaa.gov/dods/stofs_2d_glo" in url
471
+ assert "stofs_2d_glo20260219" in url
472
+ assert "stofs_2d_glo_12z" in url
473
+
474
+ def test_3d_atlantic(self):
475
+ url = self.client.build_opendap_url("3d_atlantic", "20260219", "12")
476
+ assert "stofs_3d_atl" in url
477
+ assert "stofs_3d_atl_12z" in url
478
+
479
+ def test_invalid_model(self):
480
+ with pytest.raises(ValueError):
481
+ self.client.build_opendap_url("invalid", "20260219", "12")
482
+ ```
483
+
484
+ ### Live integration test in `tests/test_live.py`
485
+
486
+ Add a test that tries to open the OPeNDAP endpoint (skip if NOMADS is down):
487
+
488
+ ```python
489
+ @pytest.mark.asyncio
490
+ async def test_opendap_endpoint_reachable(client):
491
+ """Check that the NOMADS OPeNDAP endpoint is reachable."""
492
+ from stofs_mcp.utils import resolve_latest_cycle
493
+
494
+ cycle = await resolve_latest_cycle(client, "2d_global", num_days=3)
495
+ if cycle is None:
496
+ pytest.skip("No STOFS cycle found")
497
+
498
+ date_str, hour_str = cycle
499
+ url = client.build_opendap_url("2d_global", date_str, hour_str)
500
+ available = await client.check_opendap_available(url)
501
+
502
+ if not available:
503
+ pytest.skip("NOMADS OPeNDAP not reachable (may be temporarily down)")
504
+
505
+ print(f"\nOPeNDAP reachable: {url}")
506
+
507
+
508
+ @pytest.mark.asyncio
509
+ async def test_opendap_point_extraction(client):
510
+ """Extract a single point from STOFS via OPeNDAP."""
511
+ from stofs_mcp.utils import resolve_latest_cycle, extract_point_from_opendap
512
+
513
+ cycle = await resolve_latest_cycle(client, "2d_global", num_days=3)
514
+ if cycle is None:
515
+ pytest.skip("No STOFS cycle found")
516
+
517
+ date_str, hour_str = cycle
518
+ url = client.build_opendap_url("2d_global", date_str, hour_str)
519
+
520
+ available = await client.check_opendap_available(url)
521
+ if not available:
522
+ pytest.skip("NOMADS OPeNDAP not reachable")
523
+
524
+ # The Battery, NY — well within the conus.east grid
525
+ data = extract_point_from_opendap(url, 40.7, -74.0)
526
+
527
+ print(f"\nVariable: {data['variable']}")
528
+ print(f"Grid point: ({data['actual_lat']}, {data['actual_lon']})")
529
+ print(f"Resolution: {data['grid_resolution_deg']}°")
530
+ print(f"Time steps: {data['n_times']}")
531
+
532
+ assert data["n_times"] > 0, "Expected at least some data points"
533
+ assert len(data["values"]) == len(data["times"])
534
+ # Grid point should be near the requested location
535
+ assert abs(data["actual_lat"] - 40.7) < 0.1
536
+ assert abs(data["actual_lon"] - (-74.0)) < 0.1
537
+ ```
538
+
539
+ ---
540
+
541
+ ## Update Evaluation
542
+
543
+ Add two questions to `eval/evaluation.xml`:
544
+
545
+ ```xml
546
+ <question id="11">
547
+ <prompt>Get the STOFS water level forecast at lat 36.85, lon -75.98 (Virginia Beach) using the gridded product.</prompt>
548
+ <expected_tools>stofs_get_gridded_forecast</expected_tools>
549
+ <expected_info>time series from OPeNDAP regular grid, grid snap distance, resolution</expected_info>
550
+ </question>
551
+
552
+ <question id="12">
553
+ <prompt>Compare the gridded forecast vs the station forecast near The Battery, NY. How different are they?</prompt>
554
+ <expected_tools>stofs_get_gridded_forecast, stofs_get_station_forecast</expected_tools>
555
+ <expected_info>two time series from different data sources, user can compare values</expected_info>
556
+ </question>
557
+ ```
558
+
559
+ ---
560
+
561
+ ## Update README.md
562
+
563
+ Add `stofs_get_gridded_forecast` to the tools table:
564
+
565
+ ```
566
+ | `stofs_get_gridded_forecast` | Forecast at any lat/lon via OPeNDAP (regular grid, no download) |
567
+ ```
568
+
569
+ Add an example query:
570
+ ```
571
+ - "Get the STOFS forecast at lat 36.85, lon -75.98 using the gridded product"
572
+ ```
573
+
574
+ Add a note under "Data Sources":
575
+ ```
576
+ - **NOMADS OPeNDAP**: `nomads.ncep.noaa.gov/dods/stofs_2d_glo/` (remote slice of regular-grid data, ~2-day window)
577
+ ```
578
+
579
+ ---
580
+
581
+ ## Key Implementation Notes
582
+
583
+ 1. **`asyncio.to_thread()` is mandatory.** xarray's netCDF4 engine for OPeNDAP is synchronous. Without `to_thread()`, the MCP server event loop blocks during the remote data fetch (5-30 seconds), preventing other tool calls from being processed.
584
+
585
+ 2. **NOMADS is unreliable.** OPeNDAP endpoints go down frequently — during maintenance windows, heavy load, or system updates. Every code path must handle connection failures gracefully and suggest the station-based fallback. Never let a NOMADS failure crash the server.
586
+
587
+ 3. **The variable names are not guaranteed.** NOMADS OPeNDAP variable names can change between STOFS versions. The implementation must auto-detect variable names by inspecting `ds.data_vars`, not hardcode them. The candidate list is a starting hint, not a contract.
588
+
589
+ 4. **Land points return NaN.** When the requested lat/lon falls over land, the nearest grid cell will have NaN values. Filter these out and return a clear message suggesting the user try a point closer to the coast.
590
+
591
+ 5. **The OPeNDAP grid is coarser than the native mesh.** The regular grid is ~2.5 km resolution — much coarser than the native ADCIRC mesh which has 80-120 m coastal resolution. Station point files sample the native mesh. So for locations near CO-OPS stations, the station-based tools will give more accurate results. The gridded tool is for locations far from any station.
592
+
593
+ 6. **Do not cache xarray datasets across tool calls.** Each `xr.open_dataset()` opens a network connection. Holding it open between tool calls risks stale connections, timeouts, and resource leaks. Open fresh each time — the OPeNDAP server handles caching on its side.
594
+
595
+ 7. **Longitude convention.** NOMADS OPeNDAP may use 0-360 longitude instead of -180 to 180. If the user provides negative longitude (Western hemisphere), you may need to convert: `lon_360 = longitude % 360`. Check the actual `ds.lon` values to determine which convention is in use, and convert if needed.
596
+
597
+ 8. **Timeout handling.** NOMADS OPeNDAP can be very slow (30+ seconds) during peak hours. The xarray call inside `to_thread()` does not respect the httpx timeout. Consider wrapping the `to_thread()` call in `asyncio.wait_for(coro, timeout=60)` and catching `asyncio.TimeoutError`.
598
+
599
+ ---
600
+
601
+ ## Summary: Two Data Access Strategies
602
+
603
+ After this implementation, stofs-mcp has two complementary strategies:
604
+
605
+ | | Station Files (existing) | OPeNDAP Grid (new) |
606
+ |---|---|---|
607
+ | **Source** | AWS S3 (.nc download) | NOMADS OPeNDAP (remote slice) |
608
+ | **Coverage** | ~385 fixed CO-OPS stations | Any lat/lon in grid domain |
609
+ | **Resolution** | Native mesh (80-120m coastal) | Regular grid (~2.5 km) |
610
+ | **Reliability** | High (S3 rarely down) | Medium (NOMADS can be flaky) |
611
+ | **Speed** | ~5-10 sec (download + parse) | ~5-30 sec (network dependent) |
612
+ | **Retention** | Days-weeks on S3 | ~2 days on NOMADS |
613
+ | **Best for** | Known stations, validation | Arbitrary points, exploration |
614
+ | **Tools** | stofs_get_station_forecast, stofs_get_point_forecast, stofs_compare_with_observations | stofs_get_gridded_forecast |
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 Mansur Ali Jisan
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.