ofs-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
ofs_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.
ofs_mcp-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,131 @@
1
+ Metadata-Version: 2.4
2
+ Name: ofs-mcp
3
+ Version: 0.1.0
4
+ Summary: MCP server for NOAA Operational Forecast System (OFS) regional ocean model access
5
+ Project-URL: Homepage, https://github.com/mansurjisan/ocean-mcp
6
+ Project-URL: Repository, https://github.com/mansurjisan/ocean-mcp
7
+ Project-URL: Bug Tracker, https://github.com/mansurjisan/ocean-mcp/issues
8
+ Project-URL: Documentation, https://github.com/mansurjisan/ocean-mcp/tree/main/servers/ofs-mcp
9
+ Project-URL: Changelog, https://github.com/mansurjisan/ocean-mcp/releases
10
+ Author-email: Mansur Ali Jisan <mansur.jisan@noaa.gov>
11
+ License-Expression: MIT
12
+ License-File: LICENSE
13
+ Keywords: fvcom,mcp,model-context-protocol,noaa,ocean-forecast,oceanography,ofs,roms,salinity,temperature,water-level
14
+ Classifier: Development Status :: 4 - Beta
15
+ Classifier: Intended Audience :: Developers
16
+ Classifier: Intended Audience :: Science/Research
17
+ Classifier: License :: OSI Approved :: MIT License
18
+ Classifier: Programming Language :: Python :: 3
19
+ Classifier: Programming Language :: Python :: 3.11
20
+ Classifier: Programming Language :: Python :: 3.12
21
+ Classifier: Programming Language :: Python :: 3.13
22
+ Classifier: Topic :: Scientific/Engineering :: Atmospheric Science
23
+ Classifier: Topic :: Scientific/Engineering :: GIS
24
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
25
+ Requires-Python: >=3.11
26
+ Requires-Dist: httpx>=0.27.0
27
+ Requires-Dist: mcp[cli]>=1.0.0
28
+ Requires-Dist: netcdf4>=1.7.0
29
+ Requires-Dist: numpy>=1.26.0
30
+ Requires-Dist: pydantic>=2.0.0
31
+ Description-Content-Type: text/markdown
32
+
33
+ # OFS MCP Server
34
+
35
+ <!-- mcp-name: io.github.mansurjisan/ofs-mcp -->
36
+
37
+ MCP server for NOAA's Operational Forecast System (OFS) — regional hydrodynamic ocean models covering US coastal waters.
38
+
39
+ **Status**: Ready
40
+
41
+ ## Overview
42
+
43
+ OFS comprises ~15 regional ocean models providing 48–72 hour forecasts of water levels, temperature, and salinity for US coastal bays, estuaries, and offshore waters. This server provides AI assistants with access to model metadata, cycle availability, and forecast extraction at geographic points.
44
+
45
+ ## Supported Models
46
+
47
+ | Model | Name | Grid | Region |
48
+ | --- | --- | --- | --- |
49
+ | `cbofs` | Chesapeake Bay OFS | ROMS | Chesapeake Bay, MD/VA |
50
+ | `dbofs` | Delaware Bay OFS | ROMS | Delaware Bay, DE/NJ |
51
+ | `gomofs` | Gulf of Maine OFS | ROMS | Gulf of Maine, ME/MA |
52
+ | `ngofs2` | N. Gulf of Mexico OFS v2 | FVCOM | Gulf Coast, LA/TX |
53
+ | `nyofs` | New York/NJ Harbor OFS | FVCOM | NY Harbor, NY/NJ |
54
+ | `sfbofs` | San Francisco Bay OFS | FVCOM | San Francisco Bay, CA |
55
+ | `tbofs` | Tampa Bay OFS | FVCOM | Tampa Bay, FL |
56
+ | `wcofs` | West Coast OFS | ROMS | US West Coast, CA–WA |
57
+ | `ciofs` | Cook Inlet OFS | FVCOM | Cook Inlet, AK |
58
+
59
+ All models run 4× daily (2× for WCOFS) at 00, 06, 12, 18 UTC with 6-minute output resolution.
60
+
61
+ ## Tools
62
+
63
+ | Tool | Description |
64
+ | --- | --- |
65
+ | `ofs_list_models` | List all supported models with metadata |
66
+ | `ofs_get_model_info` | Detailed specs for a specific model |
67
+ | `ofs_list_cycles` | Check S3 for available forecast cycles |
68
+ | `ofs_find_models_for_location` | Which models cover a lat/lon point |
69
+ | `ofs_get_forecast_at_point` | Forecast time series at lat/lon |
70
+ | `ofs_compare_with_coops` | Compare model vs CO-OPS observations |
71
+
72
+ ## Data Access
73
+
74
+ Model data is accessed via two strategies:
75
+ - **NOAA THREDDS OPeNDAP** (`https://opendap.co-ops.nos.noaa.gov/thredds/`): lazy remote access to BEST aggregations (most efficient, only loads requested variables/points)
76
+ - **AWS S3** (`noaa-nos-ofs-pds`): direct download for single forecast timesteps
77
+
78
+ ## Quick Start
79
+
80
+ ```bash
81
+ cd servers/ofs-mcp
82
+ uv sync
83
+ uv run ofs-mcp
84
+ ```
85
+
86
+ ### MCP Client Config
87
+
88
+ ```json
89
+ {
90
+ "mcpServers": {
91
+ "ofs": {
92
+ "command": "uv",
93
+ "args": ["--directory", "/path/to/ocean-mcp/servers/ofs-mcp", "run", "ofs-mcp"]
94
+ }
95
+ }
96
+ }
97
+ ```
98
+
99
+ ## Example Queries
100
+
101
+ - "What OFS models cover the Chesapeake Bay?"
102
+ - "List available CBOFS forecast cycles for today"
103
+ - "Get the water level forecast at lat 38.98, lon -76.48 from CBOFS"
104
+ - "Compare CBOFS water level with CO-OPS observations at station 8571892"
105
+ - "What OFS models are available for San Francisco Bay?"
106
+ - "Get temperature forecast at lat 37.8, lon -122.4 from SFBOFS"
107
+
108
+ ## Variables
109
+
110
+ All models provide surface-layer output:
111
+
112
+ | Variable | Units | Description |
113
+ | --- | --- | --- |
114
+ | `water_level` | m | Surface elevation relative to model datum |
115
+ | `temperature` | °C | Water temperature at surface sigma layer |
116
+ | `salinity` | PSU | Salinity at surface sigma layer |
117
+
118
+ ## Datum Notes
119
+
120
+ Most OFS models use **NAVD88** as their vertical datum. CO-OPS observations use either NAVD or MSL depending on the station. Small systematic offsets (1–5 cm) are expected when comparing model output to observations due to datum differences and distance between CO-OPS stations and model grid points.
121
+
122
+ ## Data Sources
123
+
124
+ - [NOAA OFS Overview](https://tidesandcurrents.noaa.gov/models.html)
125
+ - [AWS S3: noaa-nos-ofs-pds](https://registry.opendata.aws/noaa-nos-ofs/)
126
+ - [NOAA THREDDS](https://opendap.co-ops.nos.noaa.gov/thredds/)
127
+ - [CO-OPS API](https://api.tidesandcurrents.noaa.gov/api/prod/)
128
+
129
+ ## License
130
+
131
+ MIT
@@ -0,0 +1,99 @@
1
+ # OFS MCP Server
2
+
3
+ <!-- mcp-name: io.github.mansurjisan/ofs-mcp -->
4
+
5
+ MCP server for NOAA's Operational Forecast System (OFS) — regional hydrodynamic ocean models covering US coastal waters.
6
+
7
+ **Status**: Ready
8
+
9
+ ## Overview
10
+
11
+ OFS comprises ~15 regional ocean models providing 48–72 hour forecasts of water levels, temperature, and salinity for US coastal bays, estuaries, and offshore waters. This server provides AI assistants with access to model metadata, cycle availability, and forecast extraction at geographic points.
12
+
13
+ ## Supported Models
14
+
15
+ | Model | Name | Grid | Region |
16
+ | --- | --- | --- | --- |
17
+ | `cbofs` | Chesapeake Bay OFS | ROMS | Chesapeake Bay, MD/VA |
18
+ | `dbofs` | Delaware Bay OFS | ROMS | Delaware Bay, DE/NJ |
19
+ | `gomofs` | Gulf of Maine OFS | ROMS | Gulf of Maine, ME/MA |
20
+ | `ngofs2` | N. Gulf of Mexico OFS v2 | FVCOM | Gulf Coast, LA/TX |
21
+ | `nyofs` | New York/NJ Harbor OFS | FVCOM | NY Harbor, NY/NJ |
22
+ | `sfbofs` | San Francisco Bay OFS | FVCOM | San Francisco Bay, CA |
23
+ | `tbofs` | Tampa Bay OFS | FVCOM | Tampa Bay, FL |
24
+ | `wcofs` | West Coast OFS | ROMS | US West Coast, CA–WA |
25
+ | `ciofs` | Cook Inlet OFS | FVCOM | Cook Inlet, AK |
26
+
27
+ All models run 4× daily (2× for WCOFS) at 00, 06, 12, 18 UTC with 6-minute output resolution.
28
+
29
+ ## Tools
30
+
31
+ | Tool | Description |
32
+ | --- | --- |
33
+ | `ofs_list_models` | List all supported models with metadata |
34
+ | `ofs_get_model_info` | Detailed specs for a specific model |
35
+ | `ofs_list_cycles` | Check S3 for available forecast cycles |
36
+ | `ofs_find_models_for_location` | Which models cover a lat/lon point |
37
+ | `ofs_get_forecast_at_point` | Forecast time series at lat/lon |
38
+ | `ofs_compare_with_coops` | Compare model vs CO-OPS observations |
39
+
40
+ ## Data Access
41
+
42
+ Model data is accessed via two strategies:
43
+ - **NOAA THREDDS OPeNDAP** (`https://opendap.co-ops.nos.noaa.gov/thredds/`): lazy remote access to BEST aggregations (most efficient, only loads requested variables/points)
44
+ - **AWS S3** (`noaa-nos-ofs-pds`): direct download for single forecast timesteps
45
+
46
+ ## Quick Start
47
+
48
+ ```bash
49
+ cd servers/ofs-mcp
50
+ uv sync
51
+ uv run ofs-mcp
52
+ ```
53
+
54
+ ### MCP Client Config
55
+
56
+ ```json
57
+ {
58
+ "mcpServers": {
59
+ "ofs": {
60
+ "command": "uv",
61
+ "args": ["--directory", "/path/to/ocean-mcp/servers/ofs-mcp", "run", "ofs-mcp"]
62
+ }
63
+ }
64
+ }
65
+ ```
66
+
67
+ ## Example Queries
68
+
69
+ - "What OFS models cover the Chesapeake Bay?"
70
+ - "List available CBOFS forecast cycles for today"
71
+ - "Get the water level forecast at lat 38.98, lon -76.48 from CBOFS"
72
+ - "Compare CBOFS water level with CO-OPS observations at station 8571892"
73
+ - "What OFS models are available for San Francisco Bay?"
74
+ - "Get temperature forecast at lat 37.8, lon -122.4 from SFBOFS"
75
+
76
+ ## Variables
77
+
78
+ All models provide surface-layer output:
79
+
80
+ | Variable | Units | Description |
81
+ | --- | --- | --- |
82
+ | `water_level` | m | Surface elevation relative to model datum |
83
+ | `temperature` | °C | Water temperature at surface sigma layer |
84
+ | `salinity` | PSU | Salinity at surface sigma layer |
85
+
86
+ ## Datum Notes
87
+
88
+ Most OFS models use **NAVD88** as their vertical datum. CO-OPS observations use either NAVD or MSL depending on the station. Small systematic offsets (1–5 cm) are expected when comparing model output to observations due to datum differences and distance between CO-OPS stations and model grid points.
89
+
90
+ ## Data Sources
91
+
92
+ - [NOAA OFS Overview](https://tidesandcurrents.noaa.gov/models.html)
93
+ - [AWS S3: noaa-nos-ofs-pds](https://registry.opendata.aws/noaa-nos-ofs/)
94
+ - [NOAA THREDDS](https://opendap.co-ops.nos.noaa.gov/thredds/)
95
+ - [CO-OPS API](https://api.tidesandcurrents.noaa.gov/api/prod/)
96
+
97
+ ## License
98
+
99
+ MIT
@@ -0,0 +1,72 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "ofs-mcp"
7
+ version = "0.1.0"
8
+ description = "MCP server for NOAA Operational Forecast System (OFS) regional ocean model access"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ license-files = ["LICENSE"]
12
+ requires-python = ">=3.11"
13
+ authors = [
14
+ {name = "Mansur Ali Jisan", email = "mansur.jisan@noaa.gov"},
15
+ ]
16
+ keywords = [
17
+ "mcp",
18
+ "model-context-protocol",
19
+ "noaa",
20
+ "ocean-forecast",
21
+ "ofs",
22
+ "roms",
23
+ "fvcom",
24
+ "water-level",
25
+ "temperature",
26
+ "salinity",
27
+ "oceanography",
28
+ ]
29
+ classifiers = [
30
+ "Development Status :: 4 - Beta",
31
+ "Intended Audience :: Science/Research",
32
+ "Intended Audience :: Developers",
33
+ "License :: OSI Approved :: MIT License",
34
+ "Programming Language :: Python :: 3",
35
+ "Programming Language :: Python :: 3.11",
36
+ "Programming Language :: Python :: 3.12",
37
+ "Programming Language :: Python :: 3.13",
38
+ "Topic :: Scientific/Engineering :: Atmospheric Science",
39
+ "Topic :: Scientific/Engineering :: GIS",
40
+ "Topic :: Software Development :: Libraries :: Python Modules",
41
+ ]
42
+ dependencies = [
43
+ "mcp[cli]>=1.0.0",
44
+ "httpx>=0.27.0",
45
+ "pydantic>=2.0.0",
46
+ "netCDF4>=1.7.0",
47
+ "numpy>=1.26.0",
48
+ ]
49
+
50
+ [project.urls]
51
+ Homepage = "https://github.com/mansurjisan/ocean-mcp"
52
+ Repository = "https://github.com/mansurjisan/ocean-mcp"
53
+ "Bug Tracker" = "https://github.com/mansurjisan/ocean-mcp/issues"
54
+ Documentation = "https://github.com/mansurjisan/ocean-mcp/tree/main/servers/ofs-mcp"
55
+ Changelog = "https://github.com/mansurjisan/ocean-mcp/releases"
56
+
57
+ [project.scripts]
58
+ ofs-mcp = "ofs_mcp.server:main"
59
+
60
+ [tool.hatch.build.targets.wheel]
61
+ packages = ["src/ofs_mcp"]
62
+
63
+ [tool.pytest.ini_options]
64
+ testpaths = ["tests"]
65
+ asyncio_mode = "auto"
66
+
67
+ [dependency-groups]
68
+ dev = [
69
+ "pytest>=8.0.0",
70
+ "pytest-asyncio>=0.24.0",
71
+ "respx>=0.22.0",
72
+ ]
@@ -0,0 +1,22 @@
1
+ {
2
+ "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
3
+ "name": "io.github.mansurjisan/ofs-mcp",
4
+ "title": "NOAA OFS MCP Server",
5
+ "description": "MCP server for NOAA Operational Forecast System regional ocean models (CBOFS, NGOFS2, WCOFS, etc.) — water level, temperature, salinity. No API key required.",
6
+ "version": "0.1.0",
7
+ "repository": {
8
+ "url": "https://github.com/mansurjisan/ocean-mcp",
9
+ "source": "github"
10
+ },
11
+ "packages": [
12
+ {
13
+ "registryType": "pypi",
14
+ "identifier": "ofs-mcp",
15
+ "version": "0.1.0",
16
+ "runtimeHint": "uvx",
17
+ "transport": {
18
+ "type": "stdio"
19
+ }
20
+ }
21
+ ]
22
+ }
@@ -0,0 +1,9 @@
1
+ # Smithery configuration — https://smithery.ai/docs/config
2
+ startCommand:
3
+ type: stdio
4
+ configSchema:
5
+ type: object
6
+ required: []
7
+ properties: {}
8
+ commandFunction: |-
9
+ (config) => ({ command: 'uvx', args: ['ofs-mcp'] })
@@ -0,0 +1 @@
1
+ """OFS MCP — NOAA Operational Forecast System MCP server."""
@@ -0,0 +1,5 @@
1
+ """Allow running the server with `python -m ofs_mcp`."""
2
+
3
+ from .server import main
4
+
5
+ main()
@@ -0,0 +1,212 @@
1
+ """Async HTTP client for OFS data on AWS S3, THREDDS/OPeNDAP, and CO-OPS API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import tempfile
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ import httpx
10
+
11
+ from .models import COOPS_API_BASE, OFS_MODELS, S3_BASE, THREDDS_BASE
12
+
13
+
14
+ class OFSAPIError(Exception):
15
+ """Custom exception for OFS API errors."""
16
+ pass
17
+
18
+
19
+ class OFSClient:
20
+ """Async client for OFS data access and CO-OPS observations."""
21
+
22
+ def __init__(self) -> None:
23
+ self._client: httpx.AsyncClient | None = None
24
+
25
+ async def _get_client(self) -> httpx.AsyncClient:
26
+ if self._client is None or self._client.is_closed:
27
+ self._client = httpx.AsyncClient(
28
+ timeout=120.0,
29
+ follow_redirects=True,
30
+ )
31
+ return self._client
32
+
33
+ async def check_file_exists(self, url: str) -> bool:
34
+ """Check if a file exists using HTTP HEAD."""
35
+ client = await self._get_client()
36
+ try:
37
+ response = await client.head(url)
38
+ return response.status_code == 200
39
+ except Exception:
40
+ return False
41
+
42
+ def build_s3_url(
43
+ self,
44
+ model: str,
45
+ date: str,
46
+ cycle: str,
47
+ ftype: str = "f",
48
+ fhour: int = 1,
49
+ ) -> str:
50
+ """Build the S3 URL for an OFS fields NetCDF file.
51
+
52
+ Args:
53
+ model: OFS model key (e.g., 'cbofs').
54
+ date: Date in YYYYMMDD format.
55
+ cycle: Cycle hour (e.g., '00', '06', '12', '18').
56
+ ftype: 'f' for forecast, 'n' for nowcast.
57
+ fhour: Forecast/nowcast hour number (1-indexed).
58
+
59
+ Returns:
60
+ Full HTTPS URL to the NetCDF file on S3.
61
+ """
62
+ y, m, d = date[:4], date[4:6], date[6:8]
63
+ fname = f"{model}.t{cycle}z.fields.{ftype}{fhour:03d}.nc"
64
+ return f"{S3_BASE}/{model}/netcdf/{y}/{m}/{d}/{fname}"
65
+
66
+ def build_thredds_url(self, model: str) -> str:
67
+ """Build the THREDDS OPeNDAP URL for the BEST aggregation of an OFS model.
68
+
69
+ The BEST aggregation combines the most recent nowcast and forecast data
70
+ into a continuous time series accessible via OPeNDAP for lazy loading.
71
+
72
+ Args:
73
+ model: OFS model key (e.g., 'cbofs').
74
+
75
+ Returns:
76
+ OPeNDAP URL for the BEST aggregation dataset.
77
+ """
78
+ model_info = OFS_MODELS.get(model, {})
79
+ thredds_id = model_info.get("thredds_id", model.upper())
80
+ return f"{THREDDS_BASE}/{thredds_id}/{thredds_id}_BEST.nc"
81
+
82
+ async def download_netcdf(self, url: str) -> Path:
83
+ """Download a NetCDF file to a temporary location.
84
+
85
+ Args:
86
+ url: Full HTTPS URL to the NetCDF file.
87
+
88
+ Returns:
89
+ Path to the temporary file. Caller is responsible for deletion.
90
+
91
+ Raises:
92
+ httpx.HTTPStatusError: If the request fails.
93
+ """
94
+ client = await self._get_client()
95
+ response = await client.get(url)
96
+ response.raise_for_status()
97
+
98
+ tmp = tempfile.NamedTemporaryFile(suffix=".nc", delete=False)
99
+ tmp.write(response.content)
100
+ tmp.close()
101
+ return Path(tmp.name)
102
+
103
+ def open_opendap(self, model: str):
104
+ """Open a THREDDS OPeNDAP dataset for lazy remote access.
105
+
106
+ Uses netCDF4.Dataset with OPeNDAP — only loads data when variables
107
+ are actually indexed, enabling efficient point extraction.
108
+
109
+ Args:
110
+ model: OFS model key (e.g., 'cbofs').
111
+
112
+ Returns:
113
+ netCDF4.Dataset opened via OPeNDAP.
114
+
115
+ Raises:
116
+ RuntimeError: If OPeNDAP is not available or connection fails.
117
+ """
118
+ import netCDF4
119
+
120
+ url = self.build_thredds_url(model)
121
+ try:
122
+ nc = netCDF4.Dataset(url)
123
+ return nc
124
+ except Exception as e:
125
+ raise RuntimeError(
126
+ f"Failed to open OPeNDAP dataset for {model.upper()} at {url}. "
127
+ f"Error: {e}\n\n"
128
+ "Possible causes:\n"
129
+ "- THREDDS server temporarily unavailable\n"
130
+ "- netCDF4 library not compiled with DAP support\n"
131
+ "- Network connectivity issue\n\n"
132
+ "Try using a different model or check cycle availability with ofs_list_cycles."
133
+ ) from e
134
+
135
+ async def resolve_latest_cycle(
136
+ self,
137
+ model: str,
138
+ num_days: int = 2,
139
+ ) -> tuple[str, str] | None:
140
+ """Find the latest available OFS cycle on AWS S3.
141
+
142
+ Args:
143
+ model: OFS model key (e.g., 'cbofs').
144
+ num_days: Number of past days to check (default: 2).
145
+
146
+ Returns:
147
+ (date_str, cycle_str) tuple (YYYYMMDD, CC), or None if not found.
148
+ """
149
+ from datetime import datetime, timedelta, timezone
150
+
151
+ model_info = OFS_MODELS.get(model, {})
152
+ cycles = model_info.get("cycles", ["00", "06", "12", "18"])
153
+ # Check newest first
154
+ cycles_desc = sorted(cycles, reverse=True)
155
+
156
+ today = datetime.now(timezone.utc)
157
+ for day_offset in range(num_days):
158
+ date = today - timedelta(days=day_offset)
159
+ date_str = date.strftime("%Y%m%d")
160
+ for cycle in cycles_desc:
161
+ url = self.build_s3_url(model, date_str, cycle, "f", 1)
162
+ if await self.check_file_exists(url):
163
+ return date_str, cycle
164
+
165
+ return None
166
+
167
+ async def fetch_coops_observations(
168
+ self,
169
+ station_id: str,
170
+ begin_date: str,
171
+ end_date: str,
172
+ datum: str = "NAVD",
173
+ ) -> dict[str, Any]:
174
+ """Fetch CO-OPS observed water levels.
175
+
176
+ Args:
177
+ station_id: CO-OPS station ID (e.g., '8571892').
178
+ begin_date: Start date (YYYYMMDD or 'YYYYMMDD HH:MM').
179
+ end_date: End date (YYYYMMDD or 'YYYYMMDD HH:MM').
180
+ datum: Vertical datum — 'NAVD' for NAVD88, 'MSL', 'MLLW', etc.
181
+
182
+ Returns:
183
+ CO-OPS API JSON response with 'data' list.
184
+
185
+ Raises:
186
+ ValueError: If the CO-OPS API returns an error.
187
+ """
188
+ client = await self._get_client()
189
+ params = {
190
+ "station": station_id,
191
+ "product": "water_level",
192
+ "datum": datum,
193
+ "units": "metric",
194
+ "time_zone": "gmt",
195
+ "format": "json",
196
+ "begin_date": begin_date,
197
+ "end_date": end_date,
198
+ "application": "ofs_mcp",
199
+ }
200
+ response = await client.get(COOPS_API_BASE, params=params)
201
+ response.raise_for_status()
202
+ data = response.json()
203
+ if "error" in data:
204
+ raise ValueError(
205
+ f"CO-OPS API error: {data['error'].get('message', 'Unknown error')}"
206
+ )
207
+ return data
208
+
209
+ async def close(self) -> None:
210
+ """Close the HTTP client."""
211
+ if self._client and not self._client.is_closed:
212
+ await self._client.aclose()