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
|
# 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