Compare commits
No commits in common. "b928fcef1dd5a103d374c9fe5bed153bc46126ec" and "9f6d7bb4ac3b7b0a74c01c7faf3271a6c856df92" have entirely different histories.
b928fcef1d
...
9f6d7bb4ac
14
.gitignore
vendored
Normal file
14
.gitignore
vendored
Normal file
@ -0,0 +1,14 @@
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.egg-info/
|
||||
dist/
|
||||
build/
|
||||
.eggs/
|
||||
*.egg
|
||||
.venv/
|
||||
.env
|
||||
.ruff_cache/
|
||||
.pytest_cache/
|
||||
.mypy_cache/
|
||||
*.so
|
||||
8
.mcp.json
Normal file
8
.mcp.json
Normal file
@ -0,0 +1,8 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"mcnoaa-tides": {
|
||||
"command": "uv",
|
||||
"args": ["run", "--directory", "/home/rpm/claude/mat/noaa-tides", "mcnoaa-tides"]
|
||||
}
|
||||
}
|
||||
}
|
||||
282
README.md
282
README.md
@ -1,2 +1,284 @@
|
||||
# mcnoaa-tides
|
||||
|
||||
MCP server for [NOAA CO-OPS Tides and Currents](https://tidesandcurrents.noaa.gov/). Exposes tide predictions, observed water levels, and meteorological data from ~301 U.S. coastal stations as tools, resources, and prompts via [FastMCP 3.0](https://gofastmcp.com/).
|
||||
|
||||
Built for marine planning — fishing trips, boating, crabbing, safety checks.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
# Run directly (no install needed)
|
||||
uvx mcnoaa-tides
|
||||
|
||||
# Or add to Claude Code
|
||||
claude mcp add mcnoaa-tides -- uvx mcnoaa-tides
|
||||
|
||||
# With visualization support (charts)
|
||||
uv pip install mcnoaa-tides[viz]
|
||||
|
||||
# Local development
|
||||
uv run mcnoaa-tides
|
||||
```
|
||||
|
||||
## Tools
|
||||
|
||||
### `search_stations` — Find stations by name, state, or type
|
||||
|
||||
```
|
||||
search_stations(state="WA")
|
||||
```
|
||||
|
||||
Returns up to 50 matching stations:
|
||||
|
||||
```json
|
||||
[
|
||||
{"id": "9447130", "name": "Seattle", "state": "WA", "lat": 47.6026, "lng": -122.3393, "tidal": true},
|
||||
{"id": "9446484", "name": "Tacoma", "state": "WA", "lat": 47.2671, "lng": -122.4132, "tidal": true},
|
||||
{"id": "9444900", "name": "Port Townsend", "state": "WA", "lat": 48.1129, "lng": -122.7595, "tidal": true}
|
||||
]
|
||||
```
|
||||
|
||||
Also supports `query` (name search) and `is_tidal` (filter tidal vs non-tidal).
|
||||
|
||||
### `find_nearest_stations` — Proximity search by coordinates
|
||||
|
||||
```
|
||||
find_nearest_stations(latitude=47.6, longitude=-122.34, limit=3)
|
||||
```
|
||||
|
||||
Distances in nautical miles:
|
||||
|
||||
```json
|
||||
[
|
||||
{"id": "9447130", "name": "Seattle", "state": "WA", "lat": 47.6026, "lng": -122.3393, "distance_nm": 0.2},
|
||||
{"id": "9446484", "name": "Tacoma", "state": "WA", "lat": 47.2671, "lng": -122.4132, "distance_nm": 20.1},
|
||||
{"id": "9445958", "name": "Bremerton", "state": "WA", "lat": 47.5615, "lng": -122.6225, "distance_nm": 14.7}
|
||||
]
|
||||
```
|
||||
|
||||
### `get_station_info` — Expanded station metadata
|
||||
|
||||
```
|
||||
get_station_info(station_id="9447130")
|
||||
```
|
||||
|
||||
Returns sensors, datums, products, and station details. Seattle has been operating since 1899.
|
||||
|
||||
<details>
|
||||
<summary>Full response (194 lines)</summary>
|
||||
|
||||
See [`examples/station-detail-seattle.json`](examples/station-detail-seattle.json)
|
||||
</details>
|
||||
|
||||
### `get_tide_predictions` — High/low tide times
|
||||
|
||||
```
|
||||
get_tide_predictions(station_id="9447130", hours=24)
|
||||
```
|
||||
|
||||
Defaults to `hilo` interval (high/low times only) — the most useful format for planning:
|
||||
|
||||
```json
|
||||
{
|
||||
"predictions": [
|
||||
{"t": "2026-02-21 00:49", "v": "2.658", "type": "L"},
|
||||
{"t": "2026-02-21 07:08", "v": "12.261", "type": "H"},
|
||||
{"t": "2026-02-21 13:43", "v": "1.167", "type": "L"},
|
||||
{"t": "2026-02-21 19:54", "v": "9.857", "type": "H"}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
`type`: `H` = high tide, `L` = low tide. Values in feet above MLLW.
|
||||
|
||||
Other intervals: `"h"` (hourly), `"6"` (6-minute). Datum options: `MLLW`, `MSL`, `NAVD`, `STND`.
|
||||
|
||||
### `get_observed_water_levels` — Actual readings
|
||||
|
||||
```
|
||||
get_observed_water_levels(station_id="9447130", hours=3)
|
||||
```
|
||||
|
||||
6-minute interval observations. Compare with predictions to see how reality diverges:
|
||||
|
||||
```json
|
||||
{
|
||||
"metadata": {"id": "9447130", "name": "Seattle", "lat": "47.6026", "lon": "-122.3393"},
|
||||
"data": [
|
||||
{"t": "2026-02-21 17:24", "v": "7.41", "s": "0.059", "f": "0,0,0,0", "q": "p"},
|
||||
{"t": "2026-02-21 17:30", "v": "7.62", "s": "0.072", "f": "0,0,0,0", "q": "p"},
|
||||
{"t": "2026-02-21 17:36", "v": "7.803", "s": "0.069", "f": "0,0,0,0", "q": "p"}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
`q`: `"p"` = preliminary (real-time), `"v"` = verified (post-processed). `s` = standard deviation.
|
||||
|
||||
<details>
|
||||
<summary>Full 3-hour response (213 lines)</summary>
|
||||
|
||||
See [`examples/water-levels-seattle.json`](examples/water-levels-seattle.json)
|
||||
</details>
|
||||
|
||||
### `get_meteorological_data` — Weather observations
|
||||
|
||||
One tool, 8 products. Select via `product` parameter:
|
||||
|
||||
| Product | Fields | Units |
|
||||
|---------|--------|-------|
|
||||
| `air_temperature` | `v` | deg F |
|
||||
| `water_temperature` | `v` | deg F |
|
||||
| `wind` | `s` (speed), `d` (dir deg), `dr` (compass), `g` (gust) | knots |
|
||||
| `air_pressure` | `v` | millibars |
|
||||
| `conductivity` | `v` | mS/cm |
|
||||
| `visibility` | `v` | nautical miles |
|
||||
| `humidity` | `v` | percent |
|
||||
| `salinity` | `v` | PSU |
|
||||
|
||||
```
|
||||
get_meteorological_data(station_id="8454000", product="wind", hours=1)
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"metadata": {"id": "8454000", "name": "Providence", "lat": "41.8072", "lon": "-71.4007"},
|
||||
"data": [
|
||||
{"t": "2026-02-21 22:24", "s": "2.72", "d": "109.0", "dr": "ESE", "g": "3.3", "f": "0,0"},
|
||||
{"t": "2026-02-21 22:30", "s": "3.3", "d": "103.0", "dr": "ESE", "g": "3.89", "f": "0,0"},
|
||||
{"t": "2026-02-21 22:36", "s": "2.72", "d": "108.0", "dr": "ESE", "g": "3.5", "f": "0,0"}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Not all stations support all products — use `get_station_info` to check available sensors.
|
||||
|
||||
### `marine_conditions_snapshot` — Everything at once
|
||||
|
||||
```
|
||||
marine_conditions_snapshot(station_id="9447130")
|
||||
```
|
||||
|
||||
Fires 6 parallel API calls (predictions, water level, water temp, air temp, wind, pressure) and returns a combined snapshot. Products that aren't available at a station appear under `unavailable` instead of failing the whole request:
|
||||
|
||||
```json
|
||||
{
|
||||
"station_id": "9447130",
|
||||
"fetched_utc": "2026-02-22T04:15:38.291Z",
|
||||
"predictions": {"predictions": [{"t": "...", "v": "12.261", "type": "H"}, "..."]},
|
||||
"water_level": {"data": [{"t": "...", "v": "10.22", "s": "0.053", "f": "0,0,0,0", "q": "p"}, "..."]},
|
||||
"air_pressure": {"data": [{"t": "...", "v": "1012.4", "f": "0,0,0"}, "..."]},
|
||||
"unavailable": {
|
||||
"water_temperature": "ValueError: No data was found...",
|
||||
"air_temperature": "ValueError: No data was found...",
|
||||
"wind": "ValueError: No data was found..."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Best starting point for trip planning or safety assessments.
|
||||
|
||||
### `visualize_tides` — Tide prediction chart
|
||||
|
||||
```
|
||||
visualize_tides(station_id="9447130", hours=48, format="png")
|
||||
```
|
||||
|
||||
Generates a tide prediction chart with the water level curve, high/low markers, and optional observed water level overlay. PNG returns an inline image via MCP; HTML saves an interactive Plotly chart to `artifacts/charts/`.
|
||||
|
||||

|
||||
|
||||
Parameters:
|
||||
- `hours` — forecast window (default 48)
|
||||
- `include_observed` — overlay actual readings (default true)
|
||||
- `format` — `"png"` (inline image) or `"html"` (interactive file)
|
||||
|
||||
Requires `mcnoaa-tides[viz]` — install with `uv pip install mcnoaa-tides[viz]`.
|
||||
|
||||
### `visualize_conditions` — Multi-panel conditions dashboard
|
||||
|
||||
```
|
||||
visualize_conditions(station_id="9447130", hours=24, format="png")
|
||||
```
|
||||
|
||||
Generates a multi-panel dashboard with up to 4 panels: tide predictions + observed overlay, wind speed/gust, air/water temperature, and barometric pressure with trend indicator. Products unavailable at a station are omitted.
|
||||
|
||||

|
||||
|
||||
Parameters:
|
||||
- `hours` — data window (default 24)
|
||||
- `format` — `"png"` (inline image) or `"html"` (interactive file)
|
||||
|
||||
Requires `mcnoaa-tides[viz]`.
|
||||
|
||||
## Resources
|
||||
|
||||
| URI | Description |
|
||||
|-----|-------------|
|
||||
| `noaa://stations` | Full station catalog (~301 stations) |
|
||||
| `noaa://stations/{station_id}` | Expanded metadata for one station |
|
||||
| `noaa://stations/{station_id}/nearby` | Stations within 50 nm |
|
||||
|
||||
## Prompts
|
||||
|
||||
### `plan_fishing_trip`
|
||||
|
||||
Guides the LLM through station discovery, tide analysis, and weather assessment to recommend optimal fishing windows.
|
||||
|
||||
```
|
||||
plan_fishing_trip(location="Narragansett Bay", target_species="striped bass", date="20260615")
|
||||
```
|
||||
|
||||
### `marine_safety_check`
|
||||
|
||||
GO / CAUTION / NO-GO assessment based on wind, visibility, water temperature, and pressure trends.
|
||||
|
||||
```
|
||||
marine_safety_check(station_id="9447130")
|
||||
```
|
||||
|
||||
## Response field reference
|
||||
|
||||
| Field | Meaning |
|
||||
|-------|---------|
|
||||
| `t` | Timestamp (local station time) |
|
||||
| `v` | Value (water level ft, temp F, pressure mb, etc.) |
|
||||
| `type` | `H` (high) or `L` (low) — predictions only |
|
||||
| `s` | Speed (wind, knots) or sigma (water level, std deviation) |
|
||||
| `d` | Wind direction in degrees true |
|
||||
| `dr` | Wind compass direction (N, NE, SW, etc.) |
|
||||
| `g` | Wind gust speed (knots) |
|
||||
| `f` | Data quality flags (comma-separated) |
|
||||
| `q` | QA level: `p` = preliminary, `v` = verified |
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
git clone <repo-url> && cd mcnoaa-tides
|
||||
uv sync --dev
|
||||
|
||||
# Run tests (mock client, no network)
|
||||
uv run pytest tests/ -v
|
||||
|
||||
# Lint
|
||||
uv run ruff check src/
|
||||
|
||||
# Start server locally
|
||||
uv run mcnoaa-tides
|
||||
|
||||
# Headless test with Claude
|
||||
claude -p "Search for tide stations in Rhode Island" \
|
||||
--mcp-config .mcp.json \
|
||||
--allowedTools "mcp__mcnoaa-tides__*"
|
||||
```
|
||||
|
||||
## Data source
|
||||
|
||||
All data from [NOAA Center for Operational Oceanographic Products and Services (CO-OPS)](https://tidesandcurrents.noaa.gov/). No API key required. Station IDs are 7-digit numbers (e.g. `9447130` for Seattle, `8454000` for Providence).
|
||||
|
||||
Two separate APIs are used:
|
||||
- **Data API** — observations and predictions (`api.tidesandcurrents.noaa.gov/api/prod/datagetter`)
|
||||
- **Metadata API** — station info, sensors, datums (`api.tidesandcurrents.noaa.gov/mdapi/prod/webapi`)
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
|
||||
BIN
examples/conditions-dashboard-seattle.png
Normal file
BIN
examples/conditions-dashboard-seattle.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 434 KiB |
194
examples/station-detail-seattle.json
Normal file
194
examples/station-detail-seattle.json
Normal file
@ -0,0 +1,194 @@
|
||||
{
|
||||
"count": 1,
|
||||
"units": null,
|
||||
"stations": [
|
||||
{
|
||||
"tidal": true,
|
||||
"greatlakes": false,
|
||||
"shefcode": "EBSW1",
|
||||
"details": {
|
||||
"id": "9447130",
|
||||
"established": "1899-01-01 00:00:00",
|
||||
"removed": "",
|
||||
"noaachart": "18450",
|
||||
"timemeridian": -120,
|
||||
"timezone": -8.0,
|
||||
"origyear": "1988-09-13 00:00:00",
|
||||
"self": "https://api.tidesandcurrents.noaa.gov/mdapi/prod/webapi/stations/9447130/details.json"
|
||||
},
|
||||
"sensors": {
|
||||
"units": "feet",
|
||||
"sensors": [
|
||||
{
|
||||
"status": 1,
|
||||
"refdatum": "MSL",
|
||||
"sensorID": "F1",
|
||||
"name": "Barometric Pressure",
|
||||
"elevation": 18.2221,
|
||||
"message": "",
|
||||
"dcp": 1
|
||||
},
|
||||
{
|
||||
"status": 1,
|
||||
"refdatum": "MSL",
|
||||
"sensorID": "site",
|
||||
"name": "site",
|
||||
"elevation": 12.480629,
|
||||
"message": "",
|
||||
"dcp": 0
|
||||
},
|
||||
{
|
||||
"status": 1,
|
||||
"refdatum": "",
|
||||
"sensorID": "U1",
|
||||
"name": "Tsunami WL",
|
||||
"elevation": null,
|
||||
"message": "",
|
||||
"dcp": 1
|
||||
},
|
||||
{
|
||||
"status": 1,
|
||||
"refdatum": "",
|
||||
"sensorID": "Y1",
|
||||
"name": "Microwave WL",
|
||||
"elevation": null,
|
||||
"message": "",
|
||||
"dcp": 1
|
||||
}
|
||||
],
|
||||
"self": "https://api.tidesandcurrents.noaa.gov/mdapi/prod/webapi/stations/9447130/sensors.json"
|
||||
},
|
||||
"floodlevels": {
|
||||
"self": "https://api.tidesandcurrents.noaa.gov/mdapi/prod/webapi/stations/9447130/floodlevels.json"
|
||||
},
|
||||
"datums": {
|
||||
"self": "https://api.tidesandcurrents.noaa.gov/mdapi/prod/webapi/stations/9447130/datums.json"
|
||||
},
|
||||
"supersededdatums": {
|
||||
"self": "https://api.tidesandcurrents.noaa.gov/mdapi/prod/webapi/stations/9447130/supersededdatums.json"
|
||||
},
|
||||
"harmonicConstituents": {
|
||||
"self": "https://api.tidesandcurrents.noaa.gov/mdapi/prod/webapi/stations/9447130/harcon.json"
|
||||
},
|
||||
"benchmarks": {
|
||||
"self": "https://api.tidesandcurrents.noaa.gov/mdapi/prod/webapi/stations/9447130/benchmarks.json"
|
||||
},
|
||||
"tidePredOffsets": {
|
||||
"self": "https://api.tidesandcurrents.noaa.gov/mdapi/prod/webapi/stations/9447130/tidepredoffsets.json"
|
||||
},
|
||||
"ofsMapOffsets": {
|
||||
"self": "https://api.tidesandcurrents.noaa.gov/mdapi/prod/webapi/stations/9447130/ofsmapoffsets.json"
|
||||
},
|
||||
"state": "WA",
|
||||
"timezone": "PST",
|
||||
"timezonecorr": -8,
|
||||
"observedst": true,
|
||||
"stormsurge": false,
|
||||
"nearby": {
|
||||
"self": "https://api.tidesandcurrents.noaa.gov/mdapi/prod/webapi/stations/9447130/nearby.json"
|
||||
},
|
||||
"forecast": true,
|
||||
"outlook": true,
|
||||
"HTFhistorical": true,
|
||||
"HTFmonthly": true,
|
||||
"nonNavigational": false,
|
||||
"id": "9447130",
|
||||
"name": "Seattle",
|
||||
"lat": 47.60264,
|
||||
"lng": -122.3393,
|
||||
"affiliations": "NWLON",
|
||||
"portscode": null,
|
||||
"products": {
|
||||
"products": [
|
||||
{
|
||||
"name": "Water Levels",
|
||||
"value": "https://tidesandcurrents.noaa.gov/waterlevels.html?id=9447130"
|
||||
},
|
||||
{
|
||||
"name": "Reports",
|
||||
"value": "https://tidesandcurrents.noaa.gov/reports.html?id=9447130"
|
||||
},
|
||||
{
|
||||
"name": "Tide Predictions",
|
||||
"value": "https://tidesandcurrents.noaa.gov/noaatidepredictions.html?id=9447130"
|
||||
},
|
||||
{
|
||||
"name": "Meteorological",
|
||||
"value": "https://tidesandcurrents.noaa.gov/met.html?id=9447130"
|
||||
},
|
||||
{
|
||||
"name": "OFS",
|
||||
"value": "https://tidesandcurrents.noaa.gov/ofs/ofs_station.html?stname=Seattle&ofs=ssc&stnid=9447130&subdomain=0_cp"
|
||||
},
|
||||
{
|
||||
"name": "Benchmarks",
|
||||
"value": "https://tidesandcurrents.noaa.gov/benchmarks.html?id=9447130"
|
||||
},
|
||||
{
|
||||
"name": "Superseded Benchmarks",
|
||||
"value": "https://tidesandcurrents.noaa.gov/benchmarks.html?id=9447130&type=superseded"
|
||||
},
|
||||
{
|
||||
"name": "Datums",
|
||||
"value": "https://tidesandcurrents.noaa.gov/datums.html?id=9447130"
|
||||
},
|
||||
{
|
||||
"name": "Superseded Datums",
|
||||
"value": "https://tidesandcurrents.noaa.gov/datums.html?id=9447130&epoch=1"
|
||||
},
|
||||
{
|
||||
"name": "Harmonic",
|
||||
"value": "https://tidesandcurrents.noaa.gov/harcon.html?id=9447130"
|
||||
},
|
||||
{
|
||||
"name": "Inundation Analysis",
|
||||
"value": "https://tidesandcurrents.noaa.gov/inundation/AnalysisParams?id=9447130"
|
||||
},
|
||||
{
|
||||
"name": "Sea Level Trends",
|
||||
"value": "https://tidesandcurrents.noaa.gov/sltrends/sltrends_station.shtml?id=9447130"
|
||||
},
|
||||
{
|
||||
"name": "Extreme Water Levels",
|
||||
"value": "https://tidesandcurrents.noaa.gov/est/est_station.shtml?stnid=9447130"
|
||||
},
|
||||
{
|
||||
"name": "Physical Oceanagraphy",
|
||||
"value": "https://tidesandcurrents.noaa.gov/physocean.html?id=9447130"
|
||||
},
|
||||
{
|
||||
"name": "Coastal Inundation Dashboard",
|
||||
"value": "https://tidesandcurrents.noaa.gov/inundationdb/inundation.html?id=9447130"
|
||||
},
|
||||
{
|
||||
"name": "Annual High Tide Flood Outlook",
|
||||
"value": "https://tidesandcurrents.noaa.gov/high-tide-flooding/annual-outlook.html?station=9447130"
|
||||
},
|
||||
{
|
||||
"name": "Monthly High Tide Flood Outlook",
|
||||
"value": "https://tidesandcurrents.noaa.gov/high-tide-flooding/monthly-outlook.html?station=9447130"
|
||||
},
|
||||
{
|
||||
"name": "OFS Code",
|
||||
"value": "ssc"
|
||||
},
|
||||
{
|
||||
"name": "OFS Name",
|
||||
"value": "Salish Sea and Columbia River"
|
||||
}
|
||||
],
|
||||
"self": "https://api.tidesandcurrents.noaa.gov/mdapi/prod/webapi/stations/9447130/products.json"
|
||||
},
|
||||
"disclaimers": {
|
||||
"self": "https://api.tidesandcurrents.noaa.gov/mdapi/prod/webapi/stations/9447130/disclaimers.json"
|
||||
},
|
||||
"notices": {
|
||||
"self": "https://api.tidesandcurrents.noaa.gov/mdapi/prod/webapi/stations/9447130/notices.json"
|
||||
},
|
||||
"self": "https://api.tidesandcurrents.noaa.gov/mdapi/prod/webapi/stations/9447130.json",
|
||||
"expand": "details,sensors,floodlevels,datums,harcon,tidepredoffsets,ofsmapoffsets,products,disclaimers,notices",
|
||||
"tideType": "Mixed"
|
||||
}
|
||||
],
|
||||
"self": null
|
||||
}
|
||||
BIN
examples/tide-chart-seattle.png
Normal file
BIN
examples/tide-chart-seattle.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 114 KiB |
24
examples/tide-predictions-seattle.json
Normal file
24
examples/tide-predictions-seattle.json
Normal file
@ -0,0 +1,24 @@
|
||||
{
|
||||
"predictions": [
|
||||
{
|
||||
"t": "2026-02-21 00:49",
|
||||
"v": "2.658",
|
||||
"type": "L"
|
||||
},
|
||||
{
|
||||
"t": "2026-02-21 07:08",
|
||||
"v": "12.261",
|
||||
"type": "H"
|
||||
},
|
||||
{
|
||||
"t": "2026-02-21 13:43",
|
||||
"v": "1.167",
|
||||
"type": "L"
|
||||
},
|
||||
{
|
||||
"t": "2026-02-21 19:54",
|
||||
"v": "9.857",
|
||||
"type": "H"
|
||||
}
|
||||
]
|
||||
}
|
||||
213
examples/water-levels-seattle.json
Normal file
213
examples/water-levels-seattle.json
Normal file
@ -0,0 +1,213 @@
|
||||
{
|
||||
"metadata": {
|
||||
"id": "9447130",
|
||||
"name": "Seattle",
|
||||
"lat": "47.6026",
|
||||
"lon": "-122.3393"
|
||||
},
|
||||
"data": [
|
||||
{
|
||||
"t": "2026-02-21 17:24",
|
||||
"v": "7.41",
|
||||
"s": "0.059",
|
||||
"f": "0,0,0,0",
|
||||
"q": "p"
|
||||
},
|
||||
{
|
||||
"t": "2026-02-21 17:30",
|
||||
"v": "7.62",
|
||||
"s": "0.072",
|
||||
"f": "0,0,0,0",
|
||||
"q": "p"
|
||||
},
|
||||
{
|
||||
"t": "2026-02-21 17:36",
|
||||
"v": "7.803",
|
||||
"s": "0.069",
|
||||
"f": "0,0,0,0",
|
||||
"q": "p"
|
||||
},
|
||||
{
|
||||
"t": "2026-02-21 17:42",
|
||||
"v": "7.987",
|
||||
"s": "0.062",
|
||||
"f": "0,0,0,0",
|
||||
"q": "p"
|
||||
},
|
||||
{
|
||||
"t": "2026-02-21 17:48",
|
||||
"v": "8.164",
|
||||
"s": "0.062",
|
||||
"f": "0,0,0,0",
|
||||
"q": "p"
|
||||
},
|
||||
{
|
||||
"t": "2026-02-21 17:54",
|
||||
"v": "8.335",
|
||||
"s": "0.059",
|
||||
"f": "1,0,0,0",
|
||||
"q": "p"
|
||||
},
|
||||
{
|
||||
"t": "2026-02-21 18:00",
|
||||
"v": "8.499",
|
||||
"s": "0.062",
|
||||
"f": "0,0,0,0",
|
||||
"q": "p"
|
||||
},
|
||||
{
|
||||
"t": "2026-02-21 18:06",
|
||||
"v": "8.683",
|
||||
"s": "0.059",
|
||||
"f": "0,0,0,0",
|
||||
"q": "p"
|
||||
},
|
||||
{
|
||||
"t": "2026-02-21 18:12",
|
||||
"v": "8.824",
|
||||
"s": "0.062",
|
||||
"f": "0,0,0,0",
|
||||
"q": "p"
|
||||
},
|
||||
{
|
||||
"t": "2026-02-21 18:18",
|
||||
"v": "8.985",
|
||||
"s": "0.056",
|
||||
"f": "0,0,0,0",
|
||||
"q": "p"
|
||||
},
|
||||
{
|
||||
"t": "2026-02-21 18:24",
|
||||
"v": "9.158",
|
||||
"s": "0.052",
|
||||
"f": "0,0,0,0",
|
||||
"q": "p"
|
||||
},
|
||||
{
|
||||
"t": "2026-02-21 18:30",
|
||||
"v": "9.296",
|
||||
"s": "0.052",
|
||||
"f": "0,0,0,0",
|
||||
"q": "p"
|
||||
},
|
||||
{
|
||||
"t": "2026-02-21 18:36",
|
||||
"v": "9.421",
|
||||
"s": "0.043",
|
||||
"f": "0,0,0,0",
|
||||
"q": "p"
|
||||
},
|
||||
{
|
||||
"t": "2026-02-21 18:42",
|
||||
"v": "9.546",
|
||||
"s": "0.043",
|
||||
"f": "0,0,0,0",
|
||||
"q": "p"
|
||||
},
|
||||
{
|
||||
"t": "2026-02-21 18:48",
|
||||
"v": "9.667",
|
||||
"s": "0.046",
|
||||
"f": "0,0,0,0",
|
||||
"q": "p"
|
||||
},
|
||||
{
|
||||
"t": "2026-02-21 18:54",
|
||||
"v": "9.785",
|
||||
"s": "0.033",
|
||||
"f": "0,0,0,0",
|
||||
"q": "p"
|
||||
},
|
||||
{
|
||||
"t": "2026-02-21 19:00",
|
||||
"v": "9.857",
|
||||
"s": "0.03",
|
||||
"f": "1,0,0,0",
|
||||
"q": "p"
|
||||
},
|
||||
{
|
||||
"t": "2026-02-21 19:06",
|
||||
"v": "9.926",
|
||||
"s": "0.043",
|
||||
"f": "0,0,0,0",
|
||||
"q": "p"
|
||||
},
|
||||
{
|
||||
"t": "2026-02-21 19:12",
|
||||
"v": "9.995",
|
||||
"s": "0.043",
|
||||
"f": "0,0,0,0",
|
||||
"q": "p"
|
||||
},
|
||||
{
|
||||
"t": "2026-02-21 19:18",
|
||||
"v": "10.044",
|
||||
"s": "0.036",
|
||||
"f": "0,0,0,0",
|
||||
"q": "p"
|
||||
},
|
||||
{
|
||||
"t": "2026-02-21 19:24",
|
||||
"v": "10.11",
|
||||
"s": "0.03",
|
||||
"f": "1,0,0,0",
|
||||
"q": "p"
|
||||
},
|
||||
{
|
||||
"t": "2026-02-21 19:30",
|
||||
"v": "10.179",
|
||||
"s": "0.039",
|
||||
"f": "1,0,0,0",
|
||||
"q": "p"
|
||||
},
|
||||
{
|
||||
"t": "2026-02-21 19:36",
|
||||
"v": "10.202",
|
||||
"s": "0.026",
|
||||
"f": "1,0,0,0",
|
||||
"q": "p"
|
||||
},
|
||||
{
|
||||
"t": "2026-02-21 19:42",
|
||||
"v": "10.215",
|
||||
"s": "0.03",
|
||||
"f": "0,0,0,0",
|
||||
"q": "p"
|
||||
},
|
||||
{
|
||||
"t": "2026-02-21 19:48",
|
||||
"v": "10.228",
|
||||
"s": "0.023",
|
||||
"f": "0,0,0,0",
|
||||
"q": "p"
|
||||
},
|
||||
{
|
||||
"t": "2026-02-21 19:54",
|
||||
"v": "10.225",
|
||||
"s": "0.033",
|
||||
"f": "0,0,0,0",
|
||||
"q": "p"
|
||||
},
|
||||
{
|
||||
"t": "2026-02-21 20:00",
|
||||
"v": "10.218",
|
||||
"s": "0.026",
|
||||
"f": "0,0,0,0",
|
||||
"q": "p"
|
||||
},
|
||||
{
|
||||
"t": "2026-02-21 20:06",
|
||||
"v": "10.218",
|
||||
"s": "0.026",
|
||||
"f": "0,0,0,0",
|
||||
"q": "p"
|
||||
},
|
||||
{
|
||||
"t": "2026-02-21 20:12",
|
||||
"v": "10.231",
|
||||
"s": "0.03",
|
||||
"f": "1,0,0,0",
|
||||
"q": "p"
|
||||
}
|
||||
]
|
||||
}
|
||||
82
examples/wind-providence.json
Normal file
82
examples/wind-providence.json
Normal file
@ -0,0 +1,82 @@
|
||||
{
|
||||
"metadata": {
|
||||
"id": "8454000",
|
||||
"name": "Providence",
|
||||
"lat": "41.8072",
|
||||
"lon": "-71.4007"
|
||||
},
|
||||
"data": [
|
||||
{
|
||||
"t": "2026-02-21 22:24",
|
||||
"s": "2.72",
|
||||
"d": "109.0",
|
||||
"dr": "ESE",
|
||||
"g": "3.3",
|
||||
"f": "0,0"
|
||||
},
|
||||
{
|
||||
"t": "2026-02-21 22:30",
|
||||
"s": "3.3",
|
||||
"d": "103.0",
|
||||
"dr": "ESE",
|
||||
"g": "3.89",
|
||||
"f": "0,0"
|
||||
},
|
||||
{
|
||||
"t": "2026-02-21 22:36",
|
||||
"s": "2.72",
|
||||
"d": "108.0",
|
||||
"dr": "ESE",
|
||||
"g": "3.5",
|
||||
"f": "0,0"
|
||||
},
|
||||
{
|
||||
"t": "2026-02-21 22:42",
|
||||
"s": "4.08",
|
||||
"d": "93.0",
|
||||
"dr": "E",
|
||||
"g": "5.64",
|
||||
"f": "0,0"
|
||||
},
|
||||
{
|
||||
"t": "2026-02-21 22:48",
|
||||
"s": "3.89",
|
||||
"d": "81.0",
|
||||
"dr": "E",
|
||||
"g": "6.22",
|
||||
"f": "0,0"
|
||||
},
|
||||
{
|
||||
"t": "2026-02-21 22:54",
|
||||
"s": "4.08",
|
||||
"d": "77.0",
|
||||
"dr": "ENE",
|
||||
"g": "5.64",
|
||||
"f": "0,0"
|
||||
},
|
||||
{
|
||||
"t": "2026-02-21 23:00",
|
||||
"s": "3.5",
|
||||
"d": "101.0",
|
||||
"dr": "E",
|
||||
"g": "6.22",
|
||||
"f": "0,0"
|
||||
},
|
||||
{
|
||||
"t": "2026-02-21 23:06",
|
||||
"s": "3.69",
|
||||
"d": "81.0",
|
||||
"dr": "E",
|
||||
"g": "4.86",
|
||||
"f": "0,0"
|
||||
},
|
||||
{
|
||||
"t": "2026-02-21 23:12",
|
||||
"s": "3.3",
|
||||
"d": "76.0",
|
||||
"dr": "ENE",
|
||||
"g": "4.47",
|
||||
"f": "0,0"
|
||||
}
|
||||
]
|
||||
}
|
||||
35
pyproject.toml
Normal file
35
pyproject.toml
Normal file
@ -0,0 +1,35 @@
|
||||
[project]
|
||||
name = "mcnoaa-tides"
|
||||
version = "2026.02.21"
|
||||
description = "FastMCP server for NOAA CO-OPS Tides and Currents API"
|
||||
authors = [{ name = "Ryan Malloy", email = "ryan@supported.systems" }]
|
||||
requires-python = ">=3.12"
|
||||
dependencies = ["fastmcp>=3.0.1"]
|
||||
readme = "README.md"
|
||||
license = "MIT"
|
||||
|
||||
[project.optional-dependencies]
|
||||
viz = ["matplotlib>=3.8", "plotly>=5.18"]
|
||||
|
||||
[project.scripts]
|
||||
mcnoaa-tides = "mcnoaa_tides.server:main"
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["src/mcnoaa_tides"]
|
||||
|
||||
[tool.ruff]
|
||||
target-version = "py312"
|
||||
line-length = 100
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["E", "F", "I", "W"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
asyncio_mode = "auto"
|
||||
|
||||
[dependency-groups]
|
||||
dev = ["pytest>=8", "pytest-asyncio>=0.24", "ruff>=0.9", "mcnoaa-tides[viz]"]
|
||||
6
src/mcnoaa_tides/__init__.py
Normal file
6
src/mcnoaa_tides/__init__.py
Normal file
@ -0,0 +1,6 @@
|
||||
from importlib.metadata import PackageNotFoundError, version
|
||||
|
||||
try:
|
||||
__version__ = version("mcnoaa-tides")
|
||||
except PackageNotFoundError:
|
||||
__version__ = "0.0.0-dev"
|
||||
33
src/mcnoaa_tides/charts/__init__.py
Normal file
33
src/mcnoaa_tides/charts/__init__.py
Normal file
@ -0,0 +1,33 @@
|
||||
"""Chart rendering for NOAA tide and conditions data.
|
||||
|
||||
Optional dependency — requires mcnoaa-tides[viz] to be installed.
|
||||
"""
|
||||
|
||||
# Marine color palette — shared across all chart renderers
|
||||
OCEAN_BLUE = "#1B4F72"
|
||||
TEAL = "#148F77"
|
||||
SLATE = "#5D6D7E"
|
||||
SAND = "#D4A017"
|
||||
CORAL = "#E74C3C"
|
||||
BG_COLOR = "#FAFAFA"
|
||||
GRID_COLOR = "#E0E0E0"
|
||||
|
||||
|
||||
def check_deps(format: str) -> None:
|
||||
"""Raise ValueError with install hint if visualization deps are missing."""
|
||||
if format == "png":
|
||||
try:
|
||||
import matplotlib # noqa: F401
|
||||
except ImportError:
|
||||
raise ValueError(
|
||||
"PNG charts require matplotlib. Install with: "
|
||||
"uv pip install mcnoaa-tides[viz]"
|
||||
)
|
||||
elif format == "html":
|
||||
try:
|
||||
import plotly # noqa: F401
|
||||
except ImportError:
|
||||
raise ValueError(
|
||||
"HTML charts require plotly. Install with: "
|
||||
"uv pip install mcnoaa-tides[viz]"
|
||||
)
|
||||
387
src/mcnoaa_tides/charts/conditions.py
Normal file
387
src/mcnoaa_tides/charts/conditions.py
Normal file
@ -0,0 +1,387 @@
|
||||
"""Multi-panel conditions dashboard — tide + wind + temperature + pressure."""
|
||||
|
||||
import io
|
||||
from datetime import datetime
|
||||
|
||||
from mcnoaa_tides.charts import BG_COLOR, CORAL, GRID_COLOR, OCEAN_BLUE, SAND, SLATE, TEAL
|
||||
|
||||
|
||||
def _parse_time_series(data: list[dict], value_key: str = "v") -> tuple[list, list]:
|
||||
"""Extract (times, values) from NOAA data records, skipping blanks."""
|
||||
times = []
|
||||
values = []
|
||||
for d in data:
|
||||
val = d.get(value_key)
|
||||
if val is None or val == "":
|
||||
continue
|
||||
times.append(datetime.strptime(d["t"], "%Y-%m-%d %H:%M"))
|
||||
values.append(float(val))
|
||||
return times, values
|
||||
|
||||
|
||||
def _parse_wind(data: list[dict]) -> dict:
|
||||
"""Extract wind speed, gust, and direction from wind records."""
|
||||
times = []
|
||||
speeds = []
|
||||
gusts = []
|
||||
directions = []
|
||||
for d in data:
|
||||
if d.get("s") is None or d["s"] == "":
|
||||
continue
|
||||
times.append(datetime.strptime(d["t"], "%Y-%m-%d %H:%M"))
|
||||
speeds.append(float(d["s"]))
|
||||
gusts.append(float(d.get("g", d["s"])))
|
||||
directions.append(d.get("dr", ""))
|
||||
return {"times": times, "speeds": speeds, "gusts": gusts, "directions": directions}
|
||||
|
||||
|
||||
def render_conditions_png(snapshot: dict, station_name: str = "") -> bytes:
|
||||
"""Render a multi-panel conditions dashboard as PNG bytes.
|
||||
|
||||
Args:
|
||||
snapshot: Dict from marine_conditions_snapshot with predictions, water_level,
|
||||
wind, air_temperature, water_temperature, air_pressure keys.
|
||||
station_name: Station name for the chart title.
|
||||
|
||||
Returns:
|
||||
PNG image bytes.
|
||||
"""
|
||||
import matplotlib
|
||||
|
||||
matplotlib.use("Agg")
|
||||
import matplotlib.dates as mdates
|
||||
import matplotlib.pyplot as plt
|
||||
|
||||
# Determine which panels we can show (some products may be unavailable)
|
||||
has_predictions = "predictions" in snapshot
|
||||
has_water_level = "water_level" in snapshot
|
||||
has_wind = "wind" in snapshot
|
||||
has_air_temp = "air_temperature" in snapshot
|
||||
has_water_temp = "water_temperature" in snapshot
|
||||
has_pressure = "air_pressure" in snapshot
|
||||
|
||||
panels = []
|
||||
if has_predictions or has_water_level:
|
||||
panels.append("tide")
|
||||
if has_wind:
|
||||
panels.append("wind")
|
||||
if has_air_temp or has_water_temp:
|
||||
panels.append("temp")
|
||||
if has_pressure:
|
||||
panels.append("pressure")
|
||||
|
||||
if not panels:
|
||||
# Nothing to plot — return a minimal placeholder
|
||||
fig, ax = plt.subplots(figsize=(10, 3), facecolor=BG_COLOR)
|
||||
ax.text(
|
||||
0.5, 0.5, "No data available for visualization",
|
||||
transform=ax.transAxes, ha="center", va="center", fontsize=14, color=SLATE,
|
||||
)
|
||||
ax.set_facecolor(BG_COLOR)
|
||||
ax.axis("off")
|
||||
buf = io.BytesIO()
|
||||
fig.savefig(buf, format="png", dpi=150, facecolor=BG_COLOR)
|
||||
plt.close(fig)
|
||||
buf.seek(0)
|
||||
return buf.getvalue()
|
||||
|
||||
n_panels = len(panels)
|
||||
fig, axes = plt.subplots(
|
||||
n_panels, 1, figsize=(12, 3.5 * n_panels), facecolor=BG_COLOR, sharex=True,
|
||||
)
|
||||
if n_panels == 1:
|
||||
axes = [axes]
|
||||
|
||||
date_fmt = mdates.DateFormatter("%b %d\n%H:%M")
|
||||
|
||||
for i, panel in enumerate(panels):
|
||||
ax = axes[i]
|
||||
ax.set_facecolor(BG_COLOR)
|
||||
ax.grid(True, alpha=0.3, color=GRID_COLOR, zorder=0)
|
||||
ax.tick_params(colors=SLATE, labelsize=8)
|
||||
|
||||
if panel == "tide":
|
||||
# Predictions line
|
||||
if has_predictions:
|
||||
preds = snapshot["predictions"].get("predictions", [])
|
||||
if preds:
|
||||
from mcnoaa_tides.charts.tides import _parse_predictions
|
||||
|
||||
p_times, p_values, markers = _parse_predictions(preds)
|
||||
ax.plot(
|
||||
p_times, p_values, color=OCEAN_BLUE, linewidth=2,
|
||||
label="Predicted", zorder=3,
|
||||
)
|
||||
for m in markers:
|
||||
is_high = m["type"] == "H"
|
||||
color = SAND if is_high else CORAL
|
||||
ax.plot(
|
||||
m["time"], m["value"], "o", color=color, markersize=6, zorder=4,
|
||||
)
|
||||
# Observed overlay
|
||||
if has_water_level:
|
||||
obs_data = snapshot["water_level"].get("data", [])
|
||||
if obs_data:
|
||||
from mcnoaa_tides.charts.tides import _parse_observed
|
||||
|
||||
o_times, o_values = _parse_observed(obs_data)
|
||||
if o_times:
|
||||
ax.plot(
|
||||
o_times, o_values, color=TEAL, linewidth=1.5,
|
||||
linestyle="--", alpha=0.8, label="Observed", zorder=2,
|
||||
)
|
||||
ax.axhline(y=0, color=SLATE, linewidth=0.5, alpha=0.4, zorder=1)
|
||||
ax.set_ylabel("Water Level (ft)", fontsize=9, color=SLATE)
|
||||
ax.legend(loc="upper right", fontsize=8, framealpha=0.9)
|
||||
ax.set_title("Tides", fontsize=10, fontweight="bold", color=OCEAN_BLUE, loc="left")
|
||||
|
||||
elif panel == "wind":
|
||||
wind = _parse_wind(snapshot["wind"].get("data", []))
|
||||
if wind["times"]:
|
||||
ax.plot(
|
||||
wind["times"], wind["speeds"], color=OCEAN_BLUE, linewidth=1.5,
|
||||
label="Speed", zorder=3,
|
||||
)
|
||||
ax.fill_between(
|
||||
wind["times"], wind["speeds"], wind["gusts"],
|
||||
alpha=0.2, color=CORAL, label="Gust", zorder=2,
|
||||
)
|
||||
ax.plot(
|
||||
wind["times"], wind["gusts"], color=CORAL, linewidth=1,
|
||||
linestyle="--", alpha=0.6, zorder=2,
|
||||
)
|
||||
ax.set_ylabel("Wind (kn)", fontsize=9, color=SLATE)
|
||||
ax.legend(loc="upper right", fontsize=8, framealpha=0.9)
|
||||
ax.set_title("Wind", fontsize=10, fontweight="bold", color=OCEAN_BLUE, loc="left")
|
||||
|
||||
elif panel == "temp":
|
||||
if has_air_temp:
|
||||
air_data = snapshot["air_temperature"].get("data", [])
|
||||
at, av = _parse_time_series(air_data)
|
||||
if at:
|
||||
ax.plot(at, av, color=CORAL, linewidth=1.5, label="Air", zorder=3)
|
||||
if has_water_temp:
|
||||
water_data = snapshot["water_temperature"].get("data", [])
|
||||
wt, wv = _parse_time_series(water_data)
|
||||
if wt:
|
||||
ax.plot(wt, wv, color=TEAL, linewidth=1.5, label="Water", zorder=3)
|
||||
ax.set_ylabel("Temp (\u00b0F)", fontsize=9, color=SLATE)
|
||||
ax.legend(loc="upper right", fontsize=8, framealpha=0.9)
|
||||
ax.set_title(
|
||||
"Temperature", fontsize=10, fontweight="bold", color=OCEAN_BLUE, loc="left",
|
||||
)
|
||||
|
||||
elif panel == "pressure":
|
||||
press_data = snapshot["air_pressure"].get("data", [])
|
||||
pt, pv = _parse_time_series(press_data)
|
||||
if pt:
|
||||
ax.plot(pt, pv, color=SLATE, linewidth=1.5, zorder=3)
|
||||
# Trend indicator: compare first and last
|
||||
if len(pv) >= 2:
|
||||
diff = pv[-1] - pv[0]
|
||||
arrow = "\u2191" if diff > 0.5 else ("\u2193" if diff < -0.5 else "\u2192")
|
||||
ax.annotate(
|
||||
f"{arrow} {diff:+.1f} mb",
|
||||
xy=(pt[-1], pv[-1]),
|
||||
fontsize=9,
|
||||
fontweight="bold",
|
||||
color=OCEAN_BLUE,
|
||||
xytext=(10, 0),
|
||||
textcoords="offset points",
|
||||
)
|
||||
ax.set_ylabel("Pressure (mb)", fontsize=9, color=SLATE)
|
||||
ax.set_title(
|
||||
"Barometric Pressure", fontsize=10, fontweight="bold",
|
||||
color=OCEAN_BLUE, loc="left",
|
||||
)
|
||||
|
||||
# X-axis formatting on bottom panel
|
||||
axes[-1].xaxis.set_major_formatter(date_fmt)
|
||||
axes[-1].xaxis.set_major_locator(mdates.AutoDateLocator())
|
||||
|
||||
title = "Marine Conditions"
|
||||
if station_name:
|
||||
title = f"{station_name} — {title}"
|
||||
fig.suptitle(title, fontsize=14, fontweight="bold", color=OCEAN_BLUE, y=1.01)
|
||||
fig.tight_layout()
|
||||
|
||||
buf = io.BytesIO()
|
||||
fig.savefig(buf, format="png", dpi=150, bbox_inches="tight", facecolor=BG_COLOR)
|
||||
plt.close(fig)
|
||||
buf.seek(0)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def render_conditions_html(snapshot: dict, station_name: str = "") -> str:
|
||||
"""Render a multi-panel conditions dashboard as an interactive HTML string.
|
||||
|
||||
Args:
|
||||
snapshot: Dict from marine_conditions_snapshot with data product keys.
|
||||
station_name: Station name for the chart title.
|
||||
|
||||
Returns:
|
||||
Complete HTML document string.
|
||||
"""
|
||||
import plotly.graph_objects as go
|
||||
from plotly.subplots import make_subplots
|
||||
|
||||
has_predictions = "predictions" in snapshot
|
||||
has_water_level = "water_level" in snapshot
|
||||
has_wind = "wind" in snapshot
|
||||
has_air_temp = "air_temperature" in snapshot
|
||||
has_water_temp = "water_temperature" in snapshot
|
||||
has_pressure = "air_pressure" in snapshot
|
||||
|
||||
panel_titles = []
|
||||
if has_predictions or has_water_level:
|
||||
panel_titles.append("Tides")
|
||||
if has_wind:
|
||||
panel_titles.append("Wind (kn)")
|
||||
if has_air_temp or has_water_temp:
|
||||
panel_titles.append("Temperature (\u00b0F)")
|
||||
if has_pressure:
|
||||
panel_titles.append("Barometric Pressure (mb)")
|
||||
|
||||
if not panel_titles:
|
||||
return "<html><body><p>No data available for visualization.</p></body></html>"
|
||||
|
||||
n = len(panel_titles)
|
||||
fig = make_subplots(rows=n, cols=1, shared_xaxes=True, subplot_titles=panel_titles)
|
||||
|
||||
row = 1
|
||||
|
||||
# Tide panel
|
||||
if has_predictions or has_water_level:
|
||||
if has_predictions:
|
||||
preds = snapshot["predictions"].get("predictions", [])
|
||||
if preds:
|
||||
from mcnoaa_tides.charts.tides import _parse_predictions
|
||||
|
||||
p_times, p_values, markers = _parse_predictions(preds)
|
||||
fig.add_trace(
|
||||
go.Scatter(
|
||||
x=p_times, y=p_values, mode="lines", name="Predicted",
|
||||
line={"color": OCEAN_BLUE, "width": 2},
|
||||
),
|
||||
row=row, col=1,
|
||||
)
|
||||
highs = [m for m in markers if m["type"] == "H"]
|
||||
lows = [m for m in markers if m["type"] == "L"]
|
||||
if highs:
|
||||
fig.add_trace(
|
||||
go.Scatter(
|
||||
x=[m["time"] for m in highs],
|
||||
y=[m["value"] for m in highs],
|
||||
mode="markers", name="High",
|
||||
marker={"color": SAND, "size": 8, "symbol": "triangle-up"},
|
||||
),
|
||||
row=row, col=1,
|
||||
)
|
||||
if lows:
|
||||
fig.add_trace(
|
||||
go.Scatter(
|
||||
x=[m["time"] for m in lows],
|
||||
y=[m["value"] for m in lows],
|
||||
mode="markers", name="Low",
|
||||
marker={"color": CORAL, "size": 8, "symbol": "triangle-down"},
|
||||
),
|
||||
row=row, col=1,
|
||||
)
|
||||
if has_water_level:
|
||||
obs_data = snapshot["water_level"].get("data", [])
|
||||
if obs_data:
|
||||
from mcnoaa_tides.charts.tides import _parse_observed
|
||||
|
||||
o_times, o_values = _parse_observed(obs_data)
|
||||
if o_times:
|
||||
fig.add_trace(
|
||||
go.Scatter(
|
||||
x=o_times, y=o_values, mode="lines", name="Observed",
|
||||
line={"color": TEAL, "width": 1.5, "dash": "dash"},
|
||||
opacity=0.8,
|
||||
),
|
||||
row=row, col=1,
|
||||
)
|
||||
row += 1
|
||||
|
||||
# Wind panel
|
||||
if has_wind:
|
||||
wind = _parse_wind(snapshot["wind"].get("data", []))
|
||||
if wind["times"]:
|
||||
fig.add_trace(
|
||||
go.Scatter(
|
||||
x=wind["times"], y=wind["speeds"], mode="lines", name="Speed",
|
||||
line={"color": OCEAN_BLUE, "width": 1.5},
|
||||
),
|
||||
row=row, col=1,
|
||||
)
|
||||
fig.add_trace(
|
||||
go.Scatter(
|
||||
x=wind["times"], y=wind["gusts"], mode="lines", name="Gust",
|
||||
line={"color": CORAL, "width": 1, "dash": "dash"},
|
||||
opacity=0.6,
|
||||
),
|
||||
row=row, col=1,
|
||||
)
|
||||
row += 1
|
||||
|
||||
# Temperature panel
|
||||
if has_air_temp or has_water_temp:
|
||||
if has_air_temp:
|
||||
air_data = snapshot["air_temperature"].get("data", [])
|
||||
at, av = _parse_time_series(air_data)
|
||||
if at:
|
||||
fig.add_trace(
|
||||
go.Scatter(
|
||||
x=at, y=av, mode="lines", name="Air Temp",
|
||||
line={"color": CORAL, "width": 1.5},
|
||||
),
|
||||
row=row, col=1,
|
||||
)
|
||||
if has_water_temp:
|
||||
water_data = snapshot["water_temperature"].get("data", [])
|
||||
wt, wv = _parse_time_series(water_data)
|
||||
if wt:
|
||||
fig.add_trace(
|
||||
go.Scatter(
|
||||
x=wt, y=wv, mode="lines", name="Water Temp",
|
||||
line={"color": TEAL, "width": 1.5},
|
||||
),
|
||||
row=row, col=1,
|
||||
)
|
||||
row += 1
|
||||
|
||||
# Pressure panel
|
||||
if has_pressure:
|
||||
press_data = snapshot["air_pressure"].get("data", [])
|
||||
pt, pv = _parse_time_series(press_data)
|
||||
if pt:
|
||||
fig.add_trace(
|
||||
go.Scatter(
|
||||
x=pt, y=pv, mode="lines", name="Pressure",
|
||||
line={"color": SLATE, "width": 1.5},
|
||||
),
|
||||
row=row, col=1,
|
||||
)
|
||||
|
||||
title = "Marine Conditions"
|
||||
if station_name:
|
||||
title = f"{station_name} — {title}"
|
||||
|
||||
fig.update_layout(
|
||||
title={"text": title, "font": {"size": 16, "color": OCEAN_BLUE}},
|
||||
plot_bgcolor=BG_COLOR,
|
||||
paper_bgcolor=BG_COLOR,
|
||||
font={"color": SLATE},
|
||||
hovermode="x unified",
|
||||
height=300 * n,
|
||||
legend={"orientation": "h", "yanchor": "bottom", "y": 1.02, "xanchor": "right", "x": 1},
|
||||
margin={"t": 80, "b": 40, "l": 60, "r": 20},
|
||||
)
|
||||
|
||||
# Apply grid color to all axes
|
||||
for i in range(1, n + 1):
|
||||
fig.update_xaxes(gridcolor=GRID_COLOR, showgrid=True, row=i, col=1)
|
||||
fig.update_yaxes(gridcolor=GRID_COLOR, showgrid=True, row=i, col=1)
|
||||
|
||||
return fig.to_html(include_plotlyjs="cdn", full_html=True)
|
||||
223
src/mcnoaa_tides/charts/tides.py
Normal file
223
src/mcnoaa_tides/charts/tides.py
Normal file
@ -0,0 +1,223 @@
|
||||
"""Tide chart rendering — prediction curve with H/L markers and optional observed overlay."""
|
||||
|
||||
import io
|
||||
from datetime import datetime
|
||||
|
||||
from mcnoaa_tides.charts import BG_COLOR, CORAL, GRID_COLOR, OCEAN_BLUE, SAND, SLATE, TEAL
|
||||
|
||||
|
||||
def _parse_predictions(predictions: list[dict]) -> tuple[list[datetime], list[float], list[dict]]:
|
||||
"""Extract timestamps, values, and H/L markers from prediction records."""
|
||||
times = []
|
||||
values = []
|
||||
markers = []
|
||||
for p in predictions:
|
||||
dt = datetime.strptime(p["t"], "%Y-%m-%d %H:%M")
|
||||
val = float(p["v"])
|
||||
times.append(dt)
|
||||
values.append(val)
|
||||
if p.get("type") in ("H", "L"):
|
||||
markers.append({"time": dt, "value": val, "type": p["type"]})
|
||||
return times, values, markers
|
||||
|
||||
|
||||
def _parse_observed(data: list[dict]) -> tuple[list[datetime], list[float]]:
|
||||
"""Extract timestamps and values from observed water level records."""
|
||||
times = []
|
||||
values = []
|
||||
for d in data:
|
||||
if d.get("v") is None or d["v"] == "":
|
||||
continue
|
||||
dt = datetime.strptime(d["t"], "%Y-%m-%d %H:%M")
|
||||
val = float(d["v"])
|
||||
times.append(dt)
|
||||
values.append(val)
|
||||
return times, values
|
||||
|
||||
|
||||
def render_tide_chart_png(
|
||||
predictions: list[dict],
|
||||
observed: list[dict] | None = None,
|
||||
station_name: str = "",
|
||||
) -> bytes:
|
||||
"""Render a tide prediction chart as PNG bytes.
|
||||
|
||||
Args:
|
||||
predictions: List of prediction dicts with t, v, and optional type fields.
|
||||
observed: Optional list of observed water level dicts with t, v fields.
|
||||
station_name: Station name for the chart title.
|
||||
|
||||
Returns:
|
||||
PNG image bytes.
|
||||
"""
|
||||
import matplotlib
|
||||
|
||||
matplotlib.use("Agg")
|
||||
import matplotlib.dates as mdates
|
||||
import matplotlib.pyplot as plt
|
||||
|
||||
pred_times, pred_values, markers = _parse_predictions(predictions)
|
||||
|
||||
fig, ax = plt.subplots(figsize=(12, 5), facecolor=BG_COLOR)
|
||||
ax.set_facecolor(BG_COLOR)
|
||||
|
||||
# Prediction line
|
||||
ax.plot(pred_times, pred_values, color=OCEAN_BLUE, linewidth=2, label="Predicted", zorder=3)
|
||||
|
||||
# H/L markers
|
||||
for m in markers:
|
||||
is_high = m["type"] == "H"
|
||||
color = SAND if is_high else CORAL
|
||||
ax.plot(m["time"], m["value"], marker="o", color=color, markersize=8, zorder=4)
|
||||
label = f'{m["type"]} {m["value"]:.1f}ft'
|
||||
ax.annotate(
|
||||
label,
|
||||
(m["time"], m["value"]),
|
||||
textcoords="offset points",
|
||||
xytext=(0, 14 if is_high else -18),
|
||||
fontsize=8,
|
||||
fontweight="bold",
|
||||
color=color,
|
||||
ha="center",
|
||||
zorder=5,
|
||||
)
|
||||
|
||||
# Observed overlay
|
||||
if observed:
|
||||
obs_times, obs_values = _parse_observed(observed)
|
||||
if obs_times:
|
||||
ax.plot(
|
||||
obs_times,
|
||||
obs_values,
|
||||
color=TEAL,
|
||||
linewidth=1.5,
|
||||
linestyle="--",
|
||||
alpha=0.8,
|
||||
label="Observed",
|
||||
zorder=2,
|
||||
)
|
||||
|
||||
# Zero reference line (MLLW datum)
|
||||
ax.axhline(y=0, color=SLATE, linewidth=0.5, linestyle="-", alpha=0.4, zorder=1)
|
||||
|
||||
# Formatting
|
||||
ax.xaxis.set_major_formatter(mdates.DateFormatter("%b %d\n%H:%M"))
|
||||
ax.xaxis.set_major_locator(mdates.AutoDateLocator())
|
||||
ax.set_ylabel("Water Level (ft, MLLW)", fontsize=10, color=SLATE)
|
||||
ax.set_xlabel("")
|
||||
ax.tick_params(colors=SLATE, labelsize=9)
|
||||
ax.grid(True, alpha=0.3, color=GRID_COLOR, zorder=0)
|
||||
ax.legend(loc="upper right", fontsize=9, framealpha=0.9)
|
||||
|
||||
title = "Tide Predictions"
|
||||
if station_name:
|
||||
title = f"{station_name} — {title}"
|
||||
ax.set_title(title, fontsize=13, fontweight="bold", color=OCEAN_BLUE, pad=12)
|
||||
|
||||
fig.tight_layout()
|
||||
|
||||
buf = io.BytesIO()
|
||||
fig.savefig(buf, format="png", dpi=150, bbox_inches="tight", facecolor=BG_COLOR)
|
||||
plt.close(fig)
|
||||
buf.seek(0)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def render_tide_chart_html(
|
||||
predictions: list[dict],
|
||||
observed: list[dict] | None = None,
|
||||
station_name: str = "",
|
||||
) -> str:
|
||||
"""Render a tide prediction chart as an interactive HTML string.
|
||||
|
||||
Args:
|
||||
predictions: List of prediction dicts with t, v, and optional type fields.
|
||||
observed: Optional list of observed water level dicts with t, v fields.
|
||||
station_name: Station name for the chart title.
|
||||
|
||||
Returns:
|
||||
Complete HTML document string.
|
||||
"""
|
||||
import plotly.graph_objects as go
|
||||
|
||||
pred_times, pred_values, markers = _parse_predictions(predictions)
|
||||
|
||||
fig = go.Figure()
|
||||
|
||||
# Prediction line
|
||||
fig.add_trace(
|
||||
go.Scatter(
|
||||
x=pred_times,
|
||||
y=pred_values,
|
||||
mode="lines",
|
||||
name="Predicted",
|
||||
line={"color": OCEAN_BLUE, "width": 2},
|
||||
)
|
||||
)
|
||||
|
||||
# H/L markers as separate traces
|
||||
highs = [m for m in markers if m["type"] == "H"]
|
||||
lows = [m for m in markers if m["type"] == "L"]
|
||||
|
||||
if highs:
|
||||
fig.add_trace(
|
||||
go.Scatter(
|
||||
x=[m["time"] for m in highs],
|
||||
y=[m["value"] for m in highs],
|
||||
mode="markers+text",
|
||||
name="High",
|
||||
marker={"color": SAND, "size": 10, "symbol": "triangle-up"},
|
||||
text=[f'H {m["value"]:.1f}ft' for m in highs],
|
||||
textposition="top center",
|
||||
textfont={"size": 10, "color": SAND},
|
||||
)
|
||||
)
|
||||
|
||||
if lows:
|
||||
fig.add_trace(
|
||||
go.Scatter(
|
||||
x=[m["time"] for m in lows],
|
||||
y=[m["value"] for m in lows],
|
||||
mode="markers+text",
|
||||
name="Low",
|
||||
marker={"color": CORAL, "size": 10, "symbol": "triangle-down"},
|
||||
text=[f'L {m["value"]:.1f}ft' for m in lows],
|
||||
textposition="bottom center",
|
||||
textfont={"size": 10, "color": CORAL},
|
||||
)
|
||||
)
|
||||
|
||||
# Observed overlay
|
||||
if observed:
|
||||
obs_times, obs_values = _parse_observed(observed)
|
||||
if obs_times:
|
||||
fig.add_trace(
|
||||
go.Scatter(
|
||||
x=obs_times,
|
||||
y=obs_values,
|
||||
mode="lines",
|
||||
name="Observed",
|
||||
line={"color": TEAL, "width": 1.5, "dash": "dash"},
|
||||
opacity=0.8,
|
||||
)
|
||||
)
|
||||
|
||||
title = "Tide Predictions"
|
||||
if station_name:
|
||||
title = f"{station_name} — {title}"
|
||||
|
||||
fig.update_layout(
|
||||
title={"text": title, "font": {"size": 16, "color": OCEAN_BLUE}},
|
||||
xaxis_title="",
|
||||
yaxis_title="Water Level (ft, MLLW)",
|
||||
plot_bgcolor=BG_COLOR,
|
||||
paper_bgcolor=BG_COLOR,
|
||||
font={"color": SLATE},
|
||||
hovermode="x unified",
|
||||
legend={"orientation": "h", "yanchor": "bottom", "y": 1.02, "xanchor": "right", "x": 1},
|
||||
xaxis={"gridcolor": GRID_COLOR, "showgrid": True},
|
||||
yaxis={"gridcolor": GRID_COLOR, "showgrid": True, "zeroline": True},
|
||||
margin={"t": 60, "b": 40, "l": 60, "r": 20},
|
||||
)
|
||||
|
||||
return fig.to_html(include_plotlyjs="cdn", full_html=True)
|
||||
213
src/mcnoaa_tides/client.py
Normal file
213
src/mcnoaa_tides/client.py
Normal file
@ -0,0 +1,213 @@
|
||||
"""Async NOAA CO-OPS API client with station caching and proximity search."""
|
||||
|
||||
import math
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
|
||||
import httpx
|
||||
|
||||
from mcnoaa_tides.models import Station
|
||||
|
||||
DATA_URL = "https://api.tidesandcurrents.noaa.gov/api/prod/datagetter"
|
||||
META_URL = "https://api.tidesandcurrents.noaa.gov/mdapi/prod/webapi"
|
||||
|
||||
CACHE_TTL = 86400 # 24 hours
|
||||
MAX_RANGE_HOURS = 720 # NOAA API cap ~30 days
|
||||
|
||||
_STATION_ID_RE = re.compile(r"^\d{7}$")
|
||||
|
||||
|
||||
def _validate_station_id(station_id: str) -> str:
|
||||
"""NOAA station IDs are 7-digit numbers (e.g. '8454000')."""
|
||||
if not _STATION_ID_RE.match(station_id):
|
||||
raise ValueError(
|
||||
f"Invalid station ID '{station_id}': expected a 7-digit number (e.g. '8454000')"
|
||||
)
|
||||
return station_id
|
||||
|
||||
|
||||
def haversine(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
|
||||
"""Distance in nautical miles between two coordinates."""
|
||||
R = 3440.065 # Earth radius in nautical miles
|
||||
lat1, lon1, lat2, lon2 = map(math.radians, [lat1, lon1, lat2, lon2])
|
||||
dlat = lat2 - lat1
|
||||
dlon = lon2 - lon1
|
||||
a = math.sin(dlat / 2) ** 2 + math.cos(lat1) * math.cos(lat2) * math.sin(dlon / 2) ** 2
|
||||
return 2 * R * math.asin(math.sqrt(a))
|
||||
|
||||
|
||||
class NOAAClient:
|
||||
"""Async client wrapping NOAA CO-OPS data and metadata APIs.
|
||||
|
||||
Caches the station catalog in memory (~301 entries) and refreshes every 24 hours.
|
||||
The httpx.AsyncClient is created once and reused for connection pooling.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._http: httpx.AsyncClient | None = None
|
||||
self._stations: list[Station] = []
|
||||
self._cache_time: float = 0
|
||||
|
||||
async def initialize(self) -> None:
|
||||
self._http = httpx.AsyncClient(timeout=30)
|
||||
await self._refresh_stations()
|
||||
|
||||
async def close(self) -> None:
|
||||
if self._http:
|
||||
await self._http.aclose()
|
||||
|
||||
# -- Station cache --
|
||||
|
||||
async def _refresh_stations(self) -> None:
|
||||
resp = await self._http.get(f"{META_URL}/stations.json")
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
self._stations = [Station(**s) for s in data.get("stations", [])]
|
||||
self._cache_time = time.monotonic()
|
||||
|
||||
async def get_stations(self) -> list[Station]:
|
||||
if time.monotonic() - self._cache_time > CACHE_TTL:
|
||||
try:
|
||||
await self._refresh_stations()
|
||||
except Exception:
|
||||
# Serve stale data rather than failing the request.
|
||||
# If cache was never populated, re-raise.
|
||||
if not self._stations:
|
||||
raise
|
||||
print(
|
||||
"Warning: station cache refresh failed, serving stale data",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return list(self._stations)
|
||||
|
||||
# -- Metadata API --
|
||||
|
||||
async def get_station_metadata(self, station_id: str) -> dict:
|
||||
_validate_station_id(station_id)
|
||||
try:
|
||||
resp = await self._http.get(
|
||||
f"{META_URL}/stations/{station_id}.json",
|
||||
params={"expand": "details,sensors,datums,products,disclaimers"},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
except httpx.HTTPStatusError as exc:
|
||||
if exc.response.status_code == 404:
|
||||
raise ValueError(
|
||||
f"Station '{station_id}' not found. "
|
||||
"Verify the ID using search_stations."
|
||||
) from exc
|
||||
raise RuntimeError(
|
||||
f"NOAA metadata API error ({exc.response.status_code}). "
|
||||
"The service may be temporarily unavailable."
|
||||
) from exc
|
||||
data = resp.json()
|
||||
# The metadata API wraps the station in a "stations" list
|
||||
stations = data.get("stations", [])
|
||||
if stations:
|
||||
return stations[0]
|
||||
return data
|
||||
|
||||
# -- Data API --
|
||||
|
||||
async def get_data(
|
||||
self,
|
||||
station_id: str,
|
||||
product: str,
|
||||
begin_date: str = "",
|
||||
end_date: str = "",
|
||||
hours: int = 0,
|
||||
datum: str = "MLLW",
|
||||
interval: str = "",
|
||||
units: str = "english",
|
||||
time_zone: str = "lst_ldt",
|
||||
) -> dict:
|
||||
"""Fetch data from the NOAA CO-OPS data API.
|
||||
|
||||
Date format: yyyyMMdd or yyyyMMdd HH:mm
|
||||
If no date range or hours specified, defaults to last 24 hours.
|
||||
"""
|
||||
_validate_station_id(station_id)
|
||||
|
||||
if hours and (hours < 0 or hours > MAX_RANGE_HOURS):
|
||||
raise ValueError(f"hours must be between 1 and {MAX_RANGE_HOURS}, got {hours}")
|
||||
|
||||
params: dict[str, str] = {
|
||||
"station": station_id,
|
||||
"product": product,
|
||||
"datum": datum,
|
||||
"units": units,
|
||||
"time_zone": time_zone,
|
||||
"format": "json",
|
||||
"application": "mcnoaa-tides-mcp",
|
||||
}
|
||||
if begin_date:
|
||||
params["begin_date"] = begin_date
|
||||
if end_date:
|
||||
params["end_date"] = end_date
|
||||
if hours:
|
||||
params["range"] = str(hours)
|
||||
if interval:
|
||||
params["interval"] = interval
|
||||
|
||||
# Default to last 24h if no date range specified
|
||||
if not begin_date and not end_date and not hours:
|
||||
params["range"] = "24"
|
||||
|
||||
try:
|
||||
resp = await self._http.get(DATA_URL, params=params)
|
||||
resp.raise_for_status()
|
||||
except httpx.HTTPStatusError as exc:
|
||||
if exc.response.status_code == 404:
|
||||
raise ValueError(
|
||||
f"No data for station '{station_id}' product '{product}'. "
|
||||
"Use get_station_info to check available products."
|
||||
) from exc
|
||||
raise RuntimeError(
|
||||
f"NOAA data API error ({exc.response.status_code}). "
|
||||
"The service may be temporarily unavailable."
|
||||
) from exc
|
||||
result = resp.json()
|
||||
|
||||
if "error" in result:
|
||||
raise ValueError(result["error"].get("message", "Unknown NOAA API error"))
|
||||
|
||||
return result
|
||||
|
||||
# -- In-memory search --
|
||||
|
||||
async def search(
|
||||
self,
|
||||
query: str = "",
|
||||
state: str = "",
|
||||
is_tidal: bool | None = None,
|
||||
) -> list[Station]:
|
||||
"""Filter cached stations. Triggers cache refresh if TTL expired."""
|
||||
stations = await self.get_stations()
|
||||
matches = stations
|
||||
if query:
|
||||
q = query.lower()
|
||||
matches = [s for s in matches if q in s.name.lower() or q in s.id]
|
||||
if state:
|
||||
st = state.upper()
|
||||
matches = [s for s in matches if s.state and s.state.upper() == st]
|
||||
if is_tidal is not None:
|
||||
matches = [s for s in matches if s.tidal == is_tidal]
|
||||
return matches
|
||||
|
||||
async def find_nearest(
|
||||
self,
|
||||
lat: float,
|
||||
lon: float,
|
||||
limit: int = 5,
|
||||
max_distance: float = 100,
|
||||
) -> list[tuple[Station, float]]:
|
||||
"""Return stations within max_distance nautical miles, sorted by proximity."""
|
||||
stations = await self.get_stations()
|
||||
results: list[tuple[Station, float]] = []
|
||||
for station in stations:
|
||||
dist = haversine(lat, lon, station.lat, station.lng)
|
||||
if dist <= max_distance:
|
||||
results.append((station, dist))
|
||||
results.sort(key=lambda x: x[1])
|
||||
return results[:limit]
|
||||
49
src/mcnoaa_tides/models.py
Normal file
49
src/mcnoaa_tides/models.py
Normal file
@ -0,0 +1,49 @@
|
||||
"""Pydantic models for NOAA CO-OPS API responses."""
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class Station(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
id: str
|
||||
name: str
|
||||
state: str = ""
|
||||
lat: float
|
||||
lng: float
|
||||
tidal: bool = True
|
||||
greatlakes: bool = False
|
||||
shefcode: str = ""
|
||||
|
||||
|
||||
class TidePrediction(BaseModel):
|
||||
t: str = Field(description="Timestamp (local station time)")
|
||||
v: str = Field(description="Water level in feet")
|
||||
type: str | None = Field(
|
||||
None, description="H (high) or L (low) — only present for hilo interval"
|
||||
)
|
||||
|
||||
|
||||
class WaterLevelReading(BaseModel):
|
||||
t: str = Field(description="Timestamp")
|
||||
v: str = Field(description="Water level in feet")
|
||||
s: str = Field("", description="Sigma (standard deviation)")
|
||||
f: str = Field("", description="Data quality flags (comma-separated)")
|
||||
q: str = Field("", description="Quality assurance level")
|
||||
|
||||
|
||||
class WindReading(BaseModel):
|
||||
t: str = Field(description="Timestamp")
|
||||
s: str = Field(description="Speed in knots")
|
||||
d: str = Field(description="Direction in degrees true")
|
||||
dr: str = Field(description="Compass direction (N, NE, SW, etc.)")
|
||||
g: str = Field(description="Gust speed in knots")
|
||||
f: str = Field("", description="Data quality flags")
|
||||
|
||||
|
||||
class MetReading(BaseModel):
|
||||
"""Generic meteorological reading (temperature, pressure, conductivity, etc.)."""
|
||||
|
||||
t: str = Field(description="Timestamp")
|
||||
v: str = Field(description="Value")
|
||||
f: str = Field("", description="Data quality flags")
|
||||
78
src/mcnoaa_tides/prompts.py
Normal file
78
src/mcnoaa_tides/prompts.py
Normal file
@ -0,0 +1,78 @@
|
||||
"""MCP prompt templates for marine planning workflows."""
|
||||
|
||||
from fastmcp import FastMCP
|
||||
|
||||
|
||||
def register(mcp: FastMCP) -> None:
|
||||
@mcp.prompt()
|
||||
def plan_fishing_trip(
|
||||
location: str,
|
||||
target_species: str = "",
|
||||
date: str = "",
|
||||
) -> str:
|
||||
"""Guided fishing trip planning using tide and weather data.
|
||||
|
||||
Walks through station discovery, tide analysis, and conditions assessment
|
||||
to recommend optimal fishing windows.
|
||||
"""
|
||||
species_note = f"Target species: {target_species}" if target_species else ""
|
||||
date_note = f"Date: {date}" if date else "Date: today"
|
||||
|
||||
return f"""Plan a fishing trip based on marine conditions near {location}.
|
||||
{species_note}
|
||||
{date_note}
|
||||
|
||||
Workflow:
|
||||
1. Use find_nearest_stations to locate NOAA tide stations near {location}.
|
||||
Pick the closest station that supports tide predictions.
|
||||
|
||||
2. Use get_tide_predictions to find the next high and low tides.
|
||||
Incoming (rising) tide is generally better for most species —
|
||||
it pushes bait and forage into estuaries and along structure.
|
||||
|
||||
3. Use marine_conditions_snapshot to check current conditions:
|
||||
- Wind: sustained >15 kn makes small-boat fishing uncomfortable
|
||||
- Water temperature: affects species activity and feeding patterns
|
||||
- Pressure trend: falling pressure often improves bite rates
|
||||
|
||||
4. Recommend a fishing window that lines up favorable tide phase
|
||||
with manageable weather. Include:
|
||||
- Best tide window (time range)
|
||||
- Expected water level at target time
|
||||
- Weather summary (wind, temp, pressure)
|
||||
- Any safety concerns
|
||||
"""
|
||||
|
||||
@mcp.prompt()
|
||||
def marine_safety_check(station_id: str) -> str:
|
||||
"""Go/no-go safety assessment for a marine station.
|
||||
|
||||
Evaluates current conditions against common safety thresholds
|
||||
for recreational boating and fishing.
|
||||
"""
|
||||
return f"""Perform a marine safety assessment for station {station_id}.
|
||||
|
||||
Use marine_conditions_snapshot to fetch current conditions, then evaluate:
|
||||
|
||||
GO / NO-GO criteria:
|
||||
Wind:
|
||||
GO — sustained < 15 kn, gusts < 20 kn
|
||||
CAUTION — sustained 15-20 kn or gusts 20-25 kn
|
||||
NO-GO — sustained > 20 kn or gusts > 25 kn
|
||||
|
||||
Visibility:
|
||||
GO — > 2 nm
|
||||
CAUTION — 1-2 nm
|
||||
NO-GO — < 1 nm
|
||||
|
||||
Water temperature (hypothermia risk if capsized):
|
||||
LOW RISK — > 70 F
|
||||
MODERATE — 60-70 F (wear PFD, limit exposure)
|
||||
HIGH RISK — < 60 F (survival suit or dry suit recommended)
|
||||
|
||||
Pressure trend:
|
||||
Rapidly falling pressure (> 3 mb/3hr) suggests approaching storm
|
||||
|
||||
Provide a clear GO / CAUTION / NO-GO recommendation with reasoning.
|
||||
If any single factor is NO-GO, the overall assessment should be NO-GO.
|
||||
"""
|
||||
43
src/mcnoaa_tides/resources.py
Normal file
43
src/mcnoaa_tides/resources.py
Normal file
@ -0,0 +1,43 @@
|
||||
"""MCP resources: station catalog, station detail, nearby stations."""
|
||||
|
||||
import json
|
||||
|
||||
from fastmcp import Context, FastMCP
|
||||
|
||||
from mcnoaa_tides.client import NOAAClient
|
||||
|
||||
|
||||
def register(mcp: FastMCP) -> None:
|
||||
@mcp.resource("noaa://stations")
|
||||
async def station_catalog(ctx: Context) -> str:
|
||||
"""Full NOAA tide station catalog. ~301 stations with id, name, state, coordinates."""
|
||||
noaa: NOAAClient = ctx.lifespan_context["noaa_client"]
|
||||
stations = await noaa.get_stations()
|
||||
return json.dumps(
|
||||
[s.model_dump() for s in stations],
|
||||
indent=2,
|
||||
)
|
||||
|
||||
@mcp.resource("noaa://stations/{station_id}")
|
||||
async def station_detail(station_id: str, ctx: Context) -> str:
|
||||
"""Expanded metadata for a single station including sensors, datums, and products."""
|
||||
noaa: NOAAClient = ctx.lifespan_context["noaa_client"]
|
||||
metadata = await noaa.get_station_metadata(station_id)
|
||||
return json.dumps(metadata, indent=2)
|
||||
|
||||
@mcp.resource("noaa://stations/{station_id}/nearby")
|
||||
async def nearby_stations(station_id: str, ctx: Context) -> str:
|
||||
"""Stations within 50 nm of the given station, sorted by distance."""
|
||||
noaa: NOAAClient = ctx.lifespan_context["noaa_client"]
|
||||
stations = await noaa.get_stations()
|
||||
target = next((s for s in stations if s.id == station_id), None)
|
||||
if not target:
|
||||
return json.dumps({"error": f"Station {station_id} not found"})
|
||||
|
||||
nearby = await noaa.find_nearest(target.lat, target.lng, limit=10, max_distance=50)
|
||||
# Exclude the station itself
|
||||
nearby = [(s, d) for s, d in nearby if s.id != station_id]
|
||||
return json.dumps(
|
||||
[{**s.model_dump(), "distance_nm": round(d, 1)} for s, d in nearby],
|
||||
indent=2,
|
||||
)
|
||||
61
src/mcnoaa_tides/server.py
Normal file
61
src/mcnoaa_tides/server.py
Normal file
@ -0,0 +1,61 @@
|
||||
"""FastMCP server for NOAA CO-OPS Tides and Currents API."""
|
||||
|
||||
import sys
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastmcp import FastMCP
|
||||
|
||||
from mcnoaa_tides import __version__, prompts, resources
|
||||
from mcnoaa_tides.client import NOAAClient
|
||||
from mcnoaa_tides.tools import charts, conditions, meteorological, stations, tides
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(server: FastMCP):
|
||||
"""Manage the NOAAClient lifecycle — create, pre-warm station cache, close."""
|
||||
client = NOAAClient()
|
||||
try:
|
||||
await client.initialize()
|
||||
except Exception as exc:
|
||||
# Start with empty cache — will populate on first station request.
|
||||
# HTTP client still needs to exist for data fetches.
|
||||
import httpx
|
||||
|
||||
client._http = httpx.AsyncClient(timeout=30)
|
||||
print(
|
||||
f"Warning: station cache pre-warm failed ({exc}). "
|
||||
"Will retry on first request.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
try:
|
||||
yield {"noaa_client": client}
|
||||
finally:
|
||||
await client.close()
|
||||
|
||||
|
||||
mcp = FastMCP(
|
||||
"mcnoaa-tides",
|
||||
instructions=(
|
||||
"NOAA Tides & Currents data server. "
|
||||
"Provides tide predictions, observed water levels, and meteorological data "
|
||||
"for ~301 U.S. coastal stations. Start with station discovery tools, "
|
||||
"then fetch predictions or observations by station ID."
|
||||
),
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
# Register tool modules
|
||||
stations.register(mcp)
|
||||
tides.register(mcp)
|
||||
meteorological.register(mcp)
|
||||
conditions.register(mcp)
|
||||
charts.register(mcp)
|
||||
|
||||
# Register resources and prompts
|
||||
resources.register(mcp)
|
||||
prompts.register(mcp)
|
||||
|
||||
|
||||
def main():
|
||||
print(f"mcnoaa-tides v{__version__}", file=sys.stderr)
|
||||
mcp.run()
|
||||
0
src/mcnoaa_tides/tools/__init__.py
Normal file
0
src/mcnoaa_tides/tools/__init__.py
Normal file
181
src/mcnoaa_tides/tools/charts.py
Normal file
181
src/mcnoaa_tides/tools/charts.py
Normal file
@ -0,0 +1,181 @@
|
||||
"""Visualization tools — tide charts and conditions dashboards."""
|
||||
|
||||
import asyncio
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
|
||||
from fastmcp import Context, FastMCP
|
||||
from fastmcp.utilities.types import Image
|
||||
|
||||
from mcnoaa_tides.charts import check_deps
|
||||
from mcnoaa_tides.client import NOAAClient
|
||||
|
||||
|
||||
def register(mcp: FastMCP) -> None:
|
||||
@mcp.tool(tags={"visualization"})
|
||||
async def visualize_tides(
|
||||
ctx: Context,
|
||||
station_id: str,
|
||||
hours: int = 48,
|
||||
include_observed: bool = True,
|
||||
format: Literal["png", "html"] = "png",
|
||||
) -> Image | str:
|
||||
"""Generate a tide prediction chart with high/low markers.
|
||||
|
||||
Creates a visual chart of tide predictions showing the water level curve
|
||||
with high (H) and low (L) tide markers. Optionally overlays observed
|
||||
water levels as a dashed line for comparison.
|
||||
|
||||
PNG format returns an inline image. HTML format saves an interactive
|
||||
chart to artifacts/charts/ and returns the file path.
|
||||
|
||||
Requires mcnoaa-tides[viz] to be installed.
|
||||
"""
|
||||
check_deps(format)
|
||||
noaa: NOAAClient = ctx.lifespan_context["noaa_client"]
|
||||
|
||||
today = datetime.now(timezone.utc).strftime("%Y%m%d")
|
||||
|
||||
# Fetch predictions (6-minute interval for smooth curve) + hilo for markers
|
||||
predictions_raw, hilo_raw = await asyncio.gather(
|
||||
noaa.get_data(
|
||||
station_id, product="predictions", begin_date=today,
|
||||
hours=hours, interval="6",
|
||||
),
|
||||
noaa.get_data(
|
||||
station_id, product="predictions", begin_date=today,
|
||||
hours=hours, interval="hilo",
|
||||
),
|
||||
)
|
||||
|
||||
# Merge hilo type markers into the 6-minute data points
|
||||
predictions = predictions_raw.get("predictions", [])
|
||||
hilo_map = {}
|
||||
for h in hilo_raw.get("predictions", []):
|
||||
hilo_map[h["t"]] = h.get("type")
|
||||
|
||||
for p in predictions:
|
||||
if p["t"] in hilo_map:
|
||||
p["type"] = hilo_map[p["t"]]
|
||||
|
||||
# Fetch observed water levels if requested
|
||||
observed = None
|
||||
if include_observed:
|
||||
try:
|
||||
obs_raw = await noaa.get_data(
|
||||
station_id, product="water_level", hours=hours,
|
||||
)
|
||||
observed = obs_raw.get("data", [])
|
||||
except Exception:
|
||||
pass # observed overlay is optional — skip on failure
|
||||
|
||||
# Look up station name
|
||||
station_name = ""
|
||||
try:
|
||||
stations = await noaa.get_stations()
|
||||
match = [s for s in stations if s.id == station_id]
|
||||
if match:
|
||||
station_name = match[0].name
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if format == "png":
|
||||
from mcnoaa_tides.charts.tides import render_tide_chart_png
|
||||
|
||||
png_bytes = render_tide_chart_png(predictions, observed, station_name)
|
||||
return Image(data=png_bytes, format="image/png")
|
||||
else:
|
||||
from mcnoaa_tides.charts.tides import render_tide_chart_html
|
||||
|
||||
html = render_tide_chart_html(predictions, observed, station_name)
|
||||
path = _save_html(html, station_id, "tides")
|
||||
return f"Interactive tide chart saved to: {path}"
|
||||
|
||||
@mcp.tool(tags={"visualization"})
|
||||
async def visualize_conditions(
|
||||
ctx: Context,
|
||||
station_id: str,
|
||||
hours: int = 24,
|
||||
format: Literal["png", "html"] = "png",
|
||||
) -> Image | str:
|
||||
"""Generate a multi-panel marine conditions dashboard.
|
||||
|
||||
Creates a dashboard with up to 4 panels:
|
||||
- Tide predictions with observed water level overlay
|
||||
- Wind speed and gust
|
||||
- Air and water temperature
|
||||
- Barometric pressure with trend indicator
|
||||
|
||||
Products unavailable at a station are simply omitted from the dashboard.
|
||||
|
||||
PNG format returns an inline image. HTML format saves an interactive
|
||||
chart to artifacts/charts/ and returns the file path.
|
||||
|
||||
Requires mcnoaa-tides[viz] to be installed.
|
||||
"""
|
||||
check_deps(format)
|
||||
noaa: NOAAClient = ctx.lifespan_context["noaa_client"]
|
||||
|
||||
today = datetime.now(timezone.utc).strftime("%Y%m%d")
|
||||
|
||||
# Parallel fetch — same products as marine_conditions_snapshot
|
||||
requests = {
|
||||
"predictions": {
|
||||
"product": "predictions", "interval": "hilo",
|
||||
"begin_date": today, "hours": hours,
|
||||
},
|
||||
"water_level": {"product": "water_level", "hours": hours},
|
||||
"water_temperature": {"product": "water_temperature", "hours": hours},
|
||||
"air_temperature": {"product": "air_temperature", "hours": hours},
|
||||
"wind": {"product": "wind", "hours": hours},
|
||||
"air_pressure": {"product": "air_pressure", "hours": hours},
|
||||
}
|
||||
|
||||
async def fetch(name: str, params: dict) -> tuple[str, dict | None]:
|
||||
try:
|
||||
data = await noaa.get_data(station_id, **params)
|
||||
return name, data
|
||||
except Exception:
|
||||
return name, None
|
||||
|
||||
results = await asyncio.gather(
|
||||
*[fetch(name, params) for name, params in requests.items()]
|
||||
)
|
||||
|
||||
snapshot: dict = {"station_id": station_id}
|
||||
for name, data in results:
|
||||
if data is not None:
|
||||
snapshot[name] = data
|
||||
|
||||
# Look up station name
|
||||
station_name = ""
|
||||
try:
|
||||
stations = await noaa.get_stations()
|
||||
match = [s for s in stations if s.id == station_id]
|
||||
if match:
|
||||
station_name = match[0].name
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if format == "png":
|
||||
from mcnoaa_tides.charts.conditions import render_conditions_png
|
||||
|
||||
png_bytes = render_conditions_png(snapshot, station_name)
|
||||
return Image(data=png_bytes, format="image/png")
|
||||
else:
|
||||
from mcnoaa_tides.charts.conditions import render_conditions_html
|
||||
|
||||
html = render_conditions_html(snapshot, station_name)
|
||||
path = _save_html(html, station_id, "conditions")
|
||||
return f"Interactive conditions dashboard saved to: {path}"
|
||||
|
||||
|
||||
def _save_html(html: str, station_id: str, chart_type: str) -> Path:
|
||||
"""Save HTML chart to artifacts/charts/ and return the path."""
|
||||
timestamp = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S")
|
||||
out_dir = Path("artifacts/charts")
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
path = out_dir / f"{station_id}_{chart_type}_{timestamp}.html"
|
||||
path.write_text(html, encoding="utf-8")
|
||||
return path
|
||||
77
src/mcnoaa_tides/tools/conditions.py
Normal file
77
src/mcnoaa_tides/tools/conditions.py
Normal file
@ -0,0 +1,77 @@
|
||||
"""Marine conditions snapshot — parallel multi-product fetch."""
|
||||
|
||||
import asyncio
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastmcp import Context, FastMCP
|
||||
|
||||
from mcnoaa_tides.client import NOAAClient
|
||||
|
||||
|
||||
def register(mcp: FastMCP) -> None:
|
||||
@mcp.tool(tags={"planning"})
|
||||
async def marine_conditions_snapshot(
|
||||
ctx: Context,
|
||||
station_id: str,
|
||||
hours: int = 24,
|
||||
) -> dict:
|
||||
"""Get a comprehensive marine conditions snapshot in a single call.
|
||||
|
||||
Fetches tide predictions, observed water levels, and meteorological data
|
||||
in parallel (6 API calls). Products that aren't available at a station
|
||||
are reported under "unavailable" rather than failing the whole request.
|
||||
|
||||
Returns:
|
||||
station_id, timestamp, and one key per product:
|
||||
predictions — high/low tide times
|
||||
water_level — recent observed levels
|
||||
water_temperature, air_temperature, wind, air_pressure
|
||||
|
||||
This is the best starting point for trip planning or safety checks.
|
||||
"""
|
||||
noaa: NOAAClient = ctx.lifespan_context["noaa_client"]
|
||||
today = datetime.now(timezone.utc).strftime("%Y%m%d")
|
||||
|
||||
# Define the product requests — predictions get hilo + begin_date for future data
|
||||
requests = {
|
||||
"predictions": {
|
||||
"product": "predictions",
|
||||
"interval": "hilo",
|
||||
"begin_date": today,
|
||||
"hours": hours,
|
||||
},
|
||||
"water_level": {"product": "water_level", "hours": hours},
|
||||
"water_temperature": {"product": "water_temperature", "hours": hours},
|
||||
"air_temperature": {"product": "air_temperature", "hours": hours},
|
||||
"wind": {"product": "wind", "hours": hours},
|
||||
"air_pressure": {"product": "air_pressure", "hours": hours},
|
||||
}
|
||||
|
||||
async def fetch(name: str, params: dict) -> tuple[str, dict | str]:
|
||||
try:
|
||||
data = await noaa.get_data(station_id, **params)
|
||||
return name, data
|
||||
except Exception as exc:
|
||||
msg = str(exc) or type(exc).__name__
|
||||
return name, f"{type(exc).__name__}: {msg}"
|
||||
|
||||
results = await asyncio.gather(
|
||||
*[fetch(name, params) for name, params in requests.items()]
|
||||
)
|
||||
|
||||
snapshot: dict = {
|
||||
"station_id": station_id,
|
||||
"fetched_utc": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
unavailable: dict[str, str] = {}
|
||||
|
||||
for name, data in results:
|
||||
if isinstance(data, str):
|
||||
unavailable[name] = data
|
||||
else:
|
||||
snapshot[name] = data
|
||||
|
||||
if unavailable:
|
||||
snapshot["unavailable"] = unavailable
|
||||
|
||||
return snapshot
|
||||
55
src/mcnoaa_tides/tools/meteorological.py
Normal file
55
src/mcnoaa_tides/tools/meteorological.py
Normal file
@ -0,0 +1,55 @@
|
||||
"""Meteorological data tool with Literal product selector."""
|
||||
|
||||
from typing import Literal
|
||||
|
||||
from fastmcp import Context, FastMCP
|
||||
|
||||
from mcnoaa_tides.client import NOAAClient
|
||||
|
||||
MetProduct = Literal[
|
||||
"air_temperature",
|
||||
"water_temperature",
|
||||
"wind",
|
||||
"air_pressure",
|
||||
"conductivity",
|
||||
"visibility",
|
||||
"humidity",
|
||||
"salinity",
|
||||
]
|
||||
|
||||
|
||||
def register(mcp: FastMCP) -> None:
|
||||
@mcp.tool(tags={"weather"})
|
||||
async def get_meteorological_data(
|
||||
ctx: Context,
|
||||
station_id: str,
|
||||
product: MetProduct,
|
||||
begin_date: str = "",
|
||||
end_date: str = "",
|
||||
hours: int = 24,
|
||||
) -> dict:
|
||||
"""Get meteorological observations from a NOAA station.
|
||||
|
||||
Select one product at a time. Not all stations support all products —
|
||||
use get_station_info first to check available sensors.
|
||||
|
||||
Products and their response fields:
|
||||
air_temperature — t, v (deg F), f (flags)
|
||||
water_temperature — t, v (deg F), f
|
||||
wind — t, s (speed kn), d (dir deg), dr (compass), g (gust kn), f
|
||||
air_pressure — t, v (millibars), f
|
||||
conductivity — t, v (mS/cm), f
|
||||
visibility — t, v (nautical miles), f
|
||||
humidity — t, v (percent), f
|
||||
salinity — t, v (PSU), f
|
||||
|
||||
Date format: yyyyMMdd or "yyyyMMdd HH:mm".
|
||||
"""
|
||||
noaa: NOAAClient = ctx.lifespan_context["noaa_client"]
|
||||
return await noaa.get_data(
|
||||
station_id,
|
||||
product=product,
|
||||
begin_date=begin_date,
|
||||
end_date=end_date,
|
||||
hours=hours,
|
||||
)
|
||||
72
src/mcnoaa_tides/tools/stations.py
Normal file
72
src/mcnoaa_tides/tools/stations.py
Normal file
@ -0,0 +1,72 @@
|
||||
"""Station discovery tools: search, proximity, and metadata lookup."""
|
||||
|
||||
from fastmcp import Context, FastMCP
|
||||
|
||||
from mcnoaa_tides.client import NOAAClient
|
||||
|
||||
|
||||
def register(mcp: FastMCP) -> None:
|
||||
@mcp.tool(tags={"discovery"})
|
||||
async def search_stations(
|
||||
ctx: Context,
|
||||
query: str = "",
|
||||
state: str = "",
|
||||
is_tidal: bool | None = None,
|
||||
) -> list[dict]:
|
||||
"""Search NOAA tide stations by name, state abbreviation, or tidal flag.
|
||||
|
||||
Examples:
|
||||
- search_stations(query="providence") — match by name
|
||||
- search_stations(state="RI") — all Rhode Island stations
|
||||
- search_stations(state="WA", is_tidal=True) — tidal stations in Washington
|
||||
|
||||
Returns up to 50 matching stations with id, name, state, coordinates, and tidal status.
|
||||
"""
|
||||
noaa: NOAAClient = ctx.lifespan_context["noaa_client"]
|
||||
results = await noaa.search(query=query, state=state, is_tidal=is_tidal)
|
||||
return [s.model_dump() for s in results[:50]]
|
||||
|
||||
@mcp.tool(tags={"discovery"})
|
||||
async def find_nearest_stations(
|
||||
ctx: Context,
|
||||
latitude: float,
|
||||
longitude: float,
|
||||
limit: int = 5,
|
||||
max_distance_nm: float = 100,
|
||||
) -> list[dict]:
|
||||
"""Find the nearest NOAA tide stations to a coordinate.
|
||||
|
||||
Distances are in nautical miles — useful for marine planning.
|
||||
Default searches within 100 nm and returns the 5 closest stations.
|
||||
|
||||
Example: find_nearest_stations(latitude=41.49, longitude=-71.32)
|
||||
for stations near Narragansett Bay.
|
||||
"""
|
||||
noaa: NOAAClient = ctx.lifespan_context["noaa_client"]
|
||||
if limit < 1:
|
||||
raise ValueError("limit must be at least 1")
|
||||
if max_distance_nm <= 0:
|
||||
raise ValueError("max_distance_nm must be positive")
|
||||
results = await noaa.find_nearest(
|
||||
latitude, longitude, limit=limit, max_distance=max_distance_nm
|
||||
)
|
||||
return [
|
||||
{**station.model_dump(), "distance_nm": round(dist, 1)}
|
||||
for station, dist in results
|
||||
]
|
||||
|
||||
@mcp.tool(tags={"discovery"})
|
||||
async def get_station_info(
|
||||
ctx: Context,
|
||||
station_id: str,
|
||||
) -> dict:
|
||||
"""Get detailed metadata for a specific NOAA station.
|
||||
|
||||
Returns expanded info including available products, sensors, datums,
|
||||
and station details. Use this to verify what data a station supports
|
||||
before requesting observations or predictions.
|
||||
|
||||
Example: get_station_info(station_id="8454000") for Providence, RI.
|
||||
"""
|
||||
noaa: NOAAClient = ctx.lifespan_context["noaa_client"]
|
||||
return await noaa.get_station_metadata(station_id)
|
||||
69
src/mcnoaa_tides/tools/tides.py
Normal file
69
src/mcnoaa_tides/tools/tides.py
Normal file
@ -0,0 +1,69 @@
|
||||
"""Tide prediction and observed water level tools."""
|
||||
|
||||
from fastmcp import Context, FastMCP
|
||||
|
||||
from mcnoaa_tides.client import NOAAClient
|
||||
|
||||
|
||||
def register(mcp: FastMCP) -> None:
|
||||
@mcp.tool(tags={"tides"})
|
||||
async def get_tide_predictions(
|
||||
ctx: Context,
|
||||
station_id: str,
|
||||
begin_date: str = "",
|
||||
end_date: str = "",
|
||||
hours: int = 48,
|
||||
interval: str = "hilo",
|
||||
datum: str = "MLLW",
|
||||
) -> dict:
|
||||
"""Get tide predictions for a station.
|
||||
|
||||
Defaults to high/low (hilo) predictions for the next 48 hours.
|
||||
This is the most useful interval for fishing and trip planning —
|
||||
shows when tides turn rather than 6-minute incremental data.
|
||||
|
||||
Intervals: "hilo" (high/low times), "h" (hourly), "6" (6-minute).
|
||||
Datum: "MLLW" (mean lower low water), "MSL", "NAVD", "STND", etc.
|
||||
Date format: yyyyMMdd or "yyyyMMdd HH:mm".
|
||||
|
||||
Response contains 'predictions' array with fields:
|
||||
t = timestamp, v = water level (ft), type = "H" or "L" (hilo only).
|
||||
"""
|
||||
noaa: NOAAClient = ctx.lifespan_context["noaa_client"]
|
||||
return await noaa.get_data(
|
||||
station_id,
|
||||
product="predictions",
|
||||
begin_date=begin_date,
|
||||
end_date=end_date,
|
||||
hours=hours,
|
||||
interval=interval,
|
||||
datum=datum,
|
||||
)
|
||||
|
||||
@mcp.tool(tags={"tides"})
|
||||
async def get_observed_water_levels(
|
||||
ctx: Context,
|
||||
station_id: str,
|
||||
begin_date: str = "",
|
||||
end_date: str = "",
|
||||
hours: int = 24,
|
||||
datum: str = "MLLW",
|
||||
) -> dict:
|
||||
"""Get observed (actual) water level readings from a station.
|
||||
|
||||
Returns 6-minute interval observations from the last 24 hours by default.
|
||||
Compare with predictions to see how actual conditions differ from forecast.
|
||||
|
||||
Response contains 'data' array with fields:
|
||||
t = timestamp, v = water level (ft), s = sigma, f = quality flags, q = QA level.
|
||||
Quality flag "p" = preliminary, "v" = verified.
|
||||
"""
|
||||
noaa: NOAAClient = ctx.lifespan_context["noaa_client"]
|
||||
return await noaa.get_data(
|
||||
station_id,
|
||||
product="water_level",
|
||||
begin_date=begin_date,
|
||||
end_date=end_date,
|
||||
hours=hours,
|
||||
datum=datum,
|
||||
)
|
||||
154
tests/conftest.py
Normal file
154
tests/conftest.py
Normal file
@ -0,0 +1,154 @@
|
||||
"""Test fixtures with mock NOAAClient injected via lifespan."""
|
||||
|
||||
from contextlib import asynccontextmanager
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
from fastmcp import Client, FastMCP
|
||||
from fastmcp.client.transports import StreamableHttpTransport
|
||||
from fastmcp.utilities.tests import run_server_async
|
||||
|
||||
from mcnoaa_tides import prompts, resources
|
||||
from mcnoaa_tides.client import NOAAClient
|
||||
from mcnoaa_tides.tools import charts, conditions, meteorological, stations, tides
|
||||
|
||||
# Realistic station fixtures
|
||||
MOCK_STATIONS_RAW = [
|
||||
{
|
||||
"id": "8454000",
|
||||
"name": "Providence",
|
||||
"state": "RI",
|
||||
"lat": 41.8071,
|
||||
"lng": -71.4012,
|
||||
"tidal": True,
|
||||
"greatlakes": False,
|
||||
"shefcode": "PRVD1",
|
||||
},
|
||||
{
|
||||
"id": "8452660",
|
||||
"name": "Newport",
|
||||
"state": "RI",
|
||||
"lat": 41.5043,
|
||||
"lng": -71.3261,
|
||||
"tidal": True,
|
||||
"greatlakes": False,
|
||||
"shefcode": "NWPR1",
|
||||
},
|
||||
{
|
||||
"id": "8447930",
|
||||
"name": "Woods Hole",
|
||||
"state": "MA",
|
||||
"lat": 41.5236,
|
||||
"lng": -70.6714,
|
||||
"tidal": True,
|
||||
"greatlakes": False,
|
||||
"shefcode": "WHOM3",
|
||||
},
|
||||
]
|
||||
|
||||
MOCK_PREDICTIONS = {
|
||||
"predictions": [
|
||||
{"t": "2026-02-21 04:30", "v": "4.521", "type": "H"},
|
||||
{"t": "2026-02-21 10:42", "v": "-0.123", "type": "L"},
|
||||
{"t": "2026-02-21 16:55", "v": "5.012", "type": "H"},
|
||||
{"t": "2026-02-21 23:08", "v": "0.234", "type": "L"},
|
||||
]
|
||||
}
|
||||
|
||||
MOCK_WATER_LEVEL = {
|
||||
"data": [
|
||||
{"t": "2026-02-21 00:00", "v": "2.34", "s": "0.003", "f": "0,0,0,0", "q": "p"},
|
||||
{"t": "2026-02-21 00:06", "v": "2.38", "s": "0.003", "f": "0,0,0,0", "q": "p"},
|
||||
]
|
||||
}
|
||||
|
||||
MOCK_WIND = {
|
||||
"data": [
|
||||
{
|
||||
"t": "2026-02-21 00:00",
|
||||
"s": "12.5",
|
||||
"d": "225.00",
|
||||
"dr": "SW",
|
||||
"g": "18.2",
|
||||
"f": "0,0",
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
MOCK_AIR_TEMP = {"data": [{"t": "2026-02-21 00:00", "v": "42.3", "f": "0,0,0"}]}
|
||||
MOCK_WATER_TEMP = {"data": [{"t": "2026-02-21 00:00", "v": "38.7", "f": "0,0,0"}]}
|
||||
MOCK_PRESSURE = {"data": [{"t": "2026-02-21 00:00", "v": "1013.2", "f": "0,0,0"}]}
|
||||
|
||||
MOCK_METADATA = {
|
||||
"stations": [
|
||||
{
|
||||
"id": "8454000",
|
||||
"name": "Providence",
|
||||
"state": "RI",
|
||||
"lat": 41.8071,
|
||||
"lng": -71.4012,
|
||||
"tidal": True,
|
||||
"sensors": [{"name": "Water Level"}, {"name": "Air Temperature"}],
|
||||
"products": {"self": "...", "tidePredictions": "..."},
|
||||
"datums": {"datums": [{"name": "MLLW", "value": "0.0"}]},
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
def _build_mock_client() -> NOAAClient:
|
||||
"""Build a NOAAClient with mocked HTTP but real search/nearest logic."""
|
||||
from mcnoaa_tides.models import Station
|
||||
|
||||
client = NOAAClient()
|
||||
client._stations = [Station(**s) for s in MOCK_STATIONS_RAW]
|
||||
client._cache_time = float("inf") # Never expires
|
||||
client._http = AsyncMock()
|
||||
|
||||
async def mock_get_data(station_id, product, **kwargs):
|
||||
responses = {
|
||||
"predictions": MOCK_PREDICTIONS,
|
||||
"water_level": MOCK_WATER_LEVEL,
|
||||
"wind": MOCK_WIND,
|
||||
"air_temperature": MOCK_AIR_TEMP,
|
||||
"water_temperature": MOCK_WATER_TEMP,
|
||||
"air_pressure": MOCK_PRESSURE,
|
||||
}
|
||||
if product in responses:
|
||||
return responses[product]
|
||||
raise ValueError(f"No data was found for product: {product}")
|
||||
|
||||
client.get_data = mock_get_data
|
||||
|
||||
async def mock_get_station_metadata(station_id):
|
||||
return MOCK_METADATA["stations"][0]
|
||||
|
||||
client.get_station_metadata = mock_get_station_metadata
|
||||
|
||||
return client
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def _test_lifespan(server: FastMCP):
|
||||
client = _build_mock_client()
|
||||
yield {"noaa_client": client}
|
||||
|
||||
|
||||
def _build_test_server() -> FastMCP:
|
||||
mcp = FastMCP("mcnoaa-tides-test", lifespan=_test_lifespan)
|
||||
stations.register(mcp)
|
||||
tides.register(mcp)
|
||||
meteorological.register(mcp)
|
||||
conditions.register(mcp)
|
||||
charts.register(mcp)
|
||||
resources.register(mcp)
|
||||
prompts.register(mcp)
|
||||
return mcp
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def mcp_client():
|
||||
server = _build_test_server()
|
||||
async with run_server_async(server) as url:
|
||||
async with Client(StreamableHttpTransport(url)) as client:
|
||||
yield client
|
||||
175
tests/test_charts.py
Normal file
175
tests/test_charts.py
Normal file
@ -0,0 +1,175 @@
|
||||
"""Tests for chart rendering and visualization tool registration."""
|
||||
|
||||
import pytest
|
||||
from fastmcp import Client
|
||||
|
||||
from mcnoaa_tides.charts.conditions import render_conditions_html, render_conditions_png
|
||||
from mcnoaa_tides.charts.tides import (
|
||||
_parse_observed,
|
||||
_parse_predictions,
|
||||
render_tide_chart_html,
|
||||
render_tide_chart_png,
|
||||
)
|
||||
|
||||
# Mock data (matches conftest.py fixtures)
|
||||
MOCK_PREDICTIONS = {
|
||||
"predictions": [
|
||||
{"t": "2026-02-21 04:30", "v": "4.521", "type": "H"},
|
||||
{"t": "2026-02-21 10:42", "v": "-0.123", "type": "L"},
|
||||
{"t": "2026-02-21 16:55", "v": "5.012", "type": "H"},
|
||||
{"t": "2026-02-21 23:08", "v": "0.234", "type": "L"},
|
||||
]
|
||||
}
|
||||
MOCK_WATER_LEVEL = {
|
||||
"data": [
|
||||
{"t": "2026-02-21 00:00", "v": "2.34", "s": "0.003", "f": "0,0,0,0", "q": "p"},
|
||||
{"t": "2026-02-21 00:06", "v": "2.38", "s": "0.003", "f": "0,0,0,0", "q": "p"},
|
||||
]
|
||||
}
|
||||
MOCK_WIND = {
|
||||
"data": [
|
||||
{"t": "2026-02-21 00:00", "s": "12.5", "d": "225.00", "dr": "SW", "g": "18.2", "f": "0,0"}
|
||||
]
|
||||
}
|
||||
MOCK_AIR_TEMP = {"data": [{"t": "2026-02-21 00:00", "v": "42.3", "f": "0,0,0"}]}
|
||||
MOCK_WATER_TEMP = {"data": [{"t": "2026-02-21 00:00", "v": "38.7", "f": "0,0,0"}]}
|
||||
MOCK_PRESSURE = {"data": [{"t": "2026-02-21 00:00", "v": "1013.2", "f": "0,0,0"}]}
|
||||
|
||||
|
||||
# -- Parsing helpers --
|
||||
|
||||
|
||||
def test_parse_predictions():
|
||||
preds = MOCK_PREDICTIONS["predictions"]
|
||||
times, values, markers = _parse_predictions(preds)
|
||||
assert len(times) == 4
|
||||
assert len(values) == 4
|
||||
assert len(markers) == 4
|
||||
assert markers[0]["type"] == "H"
|
||||
assert markers[1]["type"] == "L"
|
||||
assert values[0] == pytest.approx(4.521)
|
||||
|
||||
|
||||
def test_parse_observed():
|
||||
obs = MOCK_WATER_LEVEL["data"]
|
||||
times, values = _parse_observed(obs)
|
||||
assert len(times) == 2
|
||||
assert values[0] == pytest.approx(2.34)
|
||||
|
||||
|
||||
def test_parse_observed_skips_blank_values():
|
||||
data = [
|
||||
{"t": "2026-02-21 00:00", "v": "2.34"},
|
||||
{"t": "2026-02-21 00:06", "v": ""},
|
||||
{"t": "2026-02-21 00:12", "v": None},
|
||||
]
|
||||
times, values = _parse_observed(data)
|
||||
assert len(times) == 1
|
||||
|
||||
|
||||
# -- Tide chart rendering --
|
||||
|
||||
|
||||
def test_tide_chart_png_returns_bytes():
|
||||
preds = MOCK_PREDICTIONS["predictions"]
|
||||
result = render_tide_chart_png(preds, station_name="Test Station")
|
||||
assert isinstance(result, bytes)
|
||||
assert len(result) > 0
|
||||
# PNG magic bytes
|
||||
assert result[:4] == b"\x89PNG"
|
||||
|
||||
|
||||
def test_tide_chart_png_with_observed():
|
||||
preds = MOCK_PREDICTIONS["predictions"]
|
||||
obs = MOCK_WATER_LEVEL["data"]
|
||||
result = render_tide_chart_png(preds, observed=obs, station_name="Providence")
|
||||
assert isinstance(result, bytes)
|
||||
assert result[:4] == b"\x89PNG"
|
||||
|
||||
|
||||
def test_tide_chart_html_returns_string():
|
||||
preds = MOCK_PREDICTIONS["predictions"]
|
||||
result = render_tide_chart_html(preds, station_name="Test Station")
|
||||
assert isinstance(result, str)
|
||||
assert "<html>" in result.lower() or "<!doctype" in result.lower()
|
||||
assert "plotly" in result.lower()
|
||||
|
||||
|
||||
def test_tide_chart_html_with_observed():
|
||||
preds = MOCK_PREDICTIONS["predictions"]
|
||||
obs = MOCK_WATER_LEVEL["data"]
|
||||
result = render_tide_chart_html(preds, observed=obs, station_name="Providence")
|
||||
assert isinstance(result, str)
|
||||
assert "Observed" in result
|
||||
|
||||
|
||||
# -- Conditions dashboard rendering --
|
||||
|
||||
|
||||
def _build_snapshot(**overrides) -> dict:
|
||||
"""Build a snapshot dict from mock data."""
|
||||
snapshot = {
|
||||
"station_id": "8454000",
|
||||
"predictions": MOCK_PREDICTIONS,
|
||||
"water_level": MOCK_WATER_LEVEL,
|
||||
"wind": MOCK_WIND,
|
||||
"air_temperature": MOCK_AIR_TEMP,
|
||||
"water_temperature": MOCK_WATER_TEMP,
|
||||
"air_pressure": MOCK_PRESSURE,
|
||||
}
|
||||
snapshot.update(overrides)
|
||||
return snapshot
|
||||
|
||||
|
||||
def test_conditions_png_returns_bytes():
|
||||
snapshot = _build_snapshot()
|
||||
result = render_conditions_png(snapshot, station_name="Providence")
|
||||
assert isinstance(result, bytes)
|
||||
assert result[:4] == b"\x89PNG"
|
||||
|
||||
|
||||
def test_conditions_png_partial_data():
|
||||
"""Dashboard should render even with missing products."""
|
||||
snapshot = _build_snapshot()
|
||||
del snapshot["wind"]
|
||||
del snapshot["air_temperature"]
|
||||
result = render_conditions_png(snapshot, station_name="Providence")
|
||||
assert isinstance(result, bytes)
|
||||
assert result[:4] == b"\x89PNG"
|
||||
|
||||
|
||||
def test_conditions_png_empty_snapshot():
|
||||
"""Dashboard with no data produces a placeholder image."""
|
||||
result = render_conditions_png({"station_id": "8454000"})
|
||||
assert isinstance(result, bytes)
|
||||
assert result[:4] == b"\x89PNG"
|
||||
|
||||
|
||||
def test_conditions_html_returns_string():
|
||||
snapshot = _build_snapshot()
|
||||
result = render_conditions_html(snapshot, station_name="Providence")
|
||||
assert isinstance(result, str)
|
||||
assert "plotly" in result.lower()
|
||||
|
||||
|
||||
def test_conditions_html_empty_snapshot():
|
||||
result = render_conditions_html({"station_id": "8454000"})
|
||||
assert isinstance(result, str)
|
||||
assert "No data available" in result
|
||||
|
||||
|
||||
# -- Tool registration --
|
||||
|
||||
|
||||
async def test_visualization_tools_registered(mcp_client: Client):
|
||||
"""The 2 new visualization tools should appear in the tool list."""
|
||||
tools = await mcp_client.list_tools()
|
||||
names = {t.name for t in tools}
|
||||
assert "visualize_tides" in names
|
||||
assert "visualize_conditions" in names
|
||||
|
||||
|
||||
async def test_total_tool_count(mcp_client: Client):
|
||||
"""Verify total tool count after adding visualization tools (7 + 2 = 9)."""
|
||||
tools = await mcp_client.list_tools()
|
||||
assert len(tools) == 9
|
||||
141
tests/test_tools_stations.py
Normal file
141
tests/test_tools_stations.py
Normal file
@ -0,0 +1,141 @@
|
||||
"""Tests for station discovery and data retrieval tools."""
|
||||
|
||||
import json
|
||||
|
||||
from fastmcp import Client
|
||||
|
||||
|
||||
async def test_tool_registration(mcp_client: Client):
|
||||
"""All 9 tools should be registered."""
|
||||
tools = await mcp_client.list_tools()
|
||||
tool_names = {t.name for t in tools}
|
||||
expected = {
|
||||
"search_stations",
|
||||
"find_nearest_stations",
|
||||
"get_station_info",
|
||||
"get_tide_predictions",
|
||||
"get_observed_water_levels",
|
||||
"get_meteorological_data",
|
||||
"marine_conditions_snapshot",
|
||||
"visualize_tides",
|
||||
"visualize_conditions",
|
||||
}
|
||||
assert expected == tool_names
|
||||
|
||||
|
||||
async def test_search_stations_by_state(mcp_client: Client):
|
||||
result = await mcp_client.call_tool("search_stations", {"state": "RI"})
|
||||
stations = json.loads(result.content[0].text)
|
||||
assert len(stations) == 2
|
||||
assert all(s["state"] == "RI" for s in stations)
|
||||
|
||||
|
||||
async def test_search_stations_by_name(mcp_client: Client):
|
||||
result = await mcp_client.call_tool("search_stations", {"query": "providence"})
|
||||
stations = json.loads(result.content[0].text)
|
||||
assert len(stations) == 1
|
||||
assert stations[0]["id"] == "8454000"
|
||||
|
||||
|
||||
async def test_search_stations_no_match(mcp_client: Client):
|
||||
result = await mcp_client.call_tool("search_stations", {"query": "nonexistent"})
|
||||
# FastMCP may return empty content list for empty results
|
||||
if result.content:
|
||||
stations = json.loads(result.content[0].text)
|
||||
assert len(stations) == 0
|
||||
else:
|
||||
# Empty content means no matches — that's correct
|
||||
pass
|
||||
|
||||
|
||||
async def test_find_nearest_stations(mcp_client: Client):
|
||||
# Search near Providence coordinates
|
||||
result = await mcp_client.call_tool(
|
||||
"find_nearest_stations",
|
||||
{"latitude": 41.8, "longitude": -71.4},
|
||||
)
|
||||
stations = json.loads(result.content[0].text)
|
||||
assert len(stations) >= 1
|
||||
# Closest should be Providence
|
||||
assert stations[0]["id"] == "8454000"
|
||||
assert "distance_nm" in stations[0]
|
||||
# Should be sorted by distance
|
||||
distances = [s["distance_nm"] for s in stations]
|
||||
assert distances == sorted(distances)
|
||||
|
||||
|
||||
async def test_get_station_info(mcp_client: Client):
|
||||
result = await mcp_client.call_tool("get_station_info", {"station_id": "8454000"})
|
||||
info = json.loads(result.content[0].text)
|
||||
assert info["id"] == "8454000"
|
||||
assert info["name"] == "Providence"
|
||||
assert "sensors" in info
|
||||
|
||||
|
||||
async def test_get_tide_predictions(mcp_client: Client):
|
||||
result = await mcp_client.call_tool(
|
||||
"get_tide_predictions", {"station_id": "8454000"}
|
||||
)
|
||||
data = json.loads(result.content[0].text)
|
||||
assert "predictions" in data
|
||||
preds = data["predictions"]
|
||||
assert len(preds) == 4
|
||||
assert preds[0]["type"] == "H"
|
||||
assert preds[1]["type"] == "L"
|
||||
|
||||
|
||||
async def test_get_observed_water_levels(mcp_client: Client):
|
||||
result = await mcp_client.call_tool(
|
||||
"get_observed_water_levels", {"station_id": "8454000"}
|
||||
)
|
||||
data = json.loads(result.content[0].text)
|
||||
assert "data" in data
|
||||
assert len(data["data"]) == 2
|
||||
|
||||
|
||||
async def test_get_meteorological_data_wind(mcp_client: Client):
|
||||
result = await mcp_client.call_tool(
|
||||
"get_meteorological_data",
|
||||
{"station_id": "8454000", "product": "wind"},
|
||||
)
|
||||
data = json.loads(result.content[0].text)
|
||||
assert "data" in data
|
||||
assert data["data"][0]["dr"] == "SW"
|
||||
|
||||
|
||||
async def test_marine_conditions_snapshot(mcp_client: Client):
|
||||
result = await mcp_client.call_tool(
|
||||
"marine_conditions_snapshot", {"station_id": "8454000"}
|
||||
)
|
||||
snapshot = json.loads(result.content[0].text)
|
||||
assert snapshot["station_id"] == "8454000"
|
||||
assert "fetched_utc" in snapshot
|
||||
assert "predictions" in snapshot
|
||||
assert "water_level" in snapshot
|
||||
assert "wind" in snapshot
|
||||
|
||||
|
||||
async def test_resource_registration(mcp_client: Client):
|
||||
"""Resources should be registered."""
|
||||
resources = await mcp_client.list_resources()
|
||||
uris = {str(r.uri) for r in resources}
|
||||
assert "noaa://stations" in uris
|
||||
|
||||
|
||||
async def test_prompt_registration(mcp_client: Client):
|
||||
"""Prompts should be registered."""
|
||||
prompts = await mcp_client.list_prompts()
|
||||
prompt_names = {p.name for p in prompts}
|
||||
assert "plan_fishing_trip" in prompt_names
|
||||
assert "marine_safety_check" in prompt_names
|
||||
|
||||
|
||||
async def test_prompt_plan_fishing_trip(mcp_client: Client):
|
||||
result = await mcp_client.get_prompt(
|
||||
"plan_fishing_trip", {"location": "Narragansett Bay"}
|
||||
)
|
||||
assert len(result.messages) >= 1
|
||||
text = result.messages[0].content
|
||||
if hasattr(text, "text"):
|
||||
text = text.text
|
||||
assert "Narragansett Bay" in str(text)
|
||||
Loading…
x
Reference in New Issue
Block a user