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.
- stofs_mcp-0.1.0/.gitignore +42 -0
- stofs_mcp-0.1.0/CLAUDE.md +614 -0
- stofs_mcp-0.1.0/LICENSE +21 -0
- stofs_mcp-0.1.0/PKG-INFO +131 -0
- stofs_mcp-0.1.0/README.md +98 -0
- stofs_mcp-0.1.0/eval/evaluation.xml +77 -0
- stofs_mcp-0.1.0/pyproject.toml +71 -0
- stofs_mcp-0.1.0/server.json +22 -0
- stofs_mcp-0.1.0/smithery.yaml +9 -0
- stofs_mcp-0.1.0/src/stofs_mcp/__init__.py +1 -0
- stofs_mcp-0.1.0/src/stofs_mcp/__main__.py +3 -0
- stofs_mcp-0.1.0/src/stofs_mcp/client.py +205 -0
- stofs_mcp-0.1.0/src/stofs_mcp/models.py +54 -0
- stofs_mcp-0.1.0/src/stofs_mcp/server.py +32 -0
- stofs_mcp-0.1.0/src/stofs_mcp/stations.py +146 -0
- stofs_mcp-0.1.0/src/stofs_mcp/tools/__init__.py +1 -0
- stofs_mcp-0.1.0/src/stofs_mcp/tools/discovery.py +311 -0
- stofs_mcp-0.1.0/src/stofs_mcp/tools/forecast.py +572 -0
- stofs_mcp-0.1.0/src/stofs_mcp/tools/validation.py +257 -0
- stofs_mcp-0.1.0/src/stofs_mcp/utils.py +819 -0
- stofs_mcp-0.1.0/tests/__init__.py +0 -0
- stofs_mcp-0.1.0/tests/test_live.py +190 -0
- stofs_mcp-0.1.0/tests/test_utils.py +326 -0
|
@@ -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 |
|
stofs_mcp-0.1.0/LICENSE
ADDED
|
@@ -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.
|