Compare commits
No commits in common. "9f6d7bb4ac3b7b0a74c01c7faf3271a6c856df92" and "b928fcef1dd5a103d374c9fe5bed153bc46126ec" have entirely different histories.
9f6d7bb4ac
...
b928fcef1d
14
.gitignore
vendored
14
.gitignore
vendored
@ -1,14 +0,0 @@
|
|||||||
__pycache__/
|
|
||||||
*.py[cod]
|
|
||||||
*$py.class
|
|
||||||
*.egg-info/
|
|
||||||
dist/
|
|
||||||
build/
|
|
||||||
.eggs/
|
|
||||||
*.egg
|
|
||||||
.venv/
|
|
||||||
.env
|
|
||||||
.ruff_cache/
|
|
||||||
.pytest_cache/
|
|
||||||
.mypy_cache/
|
|
||||||
*.so
|
|
||||||
@ -1,8 +0,0 @@
|
|||||||
{
|
|
||||||
"mcpServers": {
|
|
||||||
"mcnoaa-tides": {
|
|
||||||
"command": "uv",
|
|
||||||
"args": ["run", "--directory", "/home/rpm/claude/mat/noaa-tides", "mcnoaa-tides"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
282
README.md
282
README.md
@ -1,284 +1,2 @@
|
|||||||
# 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
|
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 434 KiB |
@ -1,194 +0,0 @@
|
|||||||
{
|
|
||||||
"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
|
|
||||||
}
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 114 KiB |
@ -1,24 +0,0 @@
|
|||||||
{
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@ -1,213 +0,0 @@
|
|||||||
{
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@ -1,82 +0,0 @@
|
|||||||
{
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@ -1,35 +0,0 @@
|
|||||||
[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]"]
|
|
||||||
@ -1,6 +0,0 @@
|
|||||||
from importlib.metadata import PackageNotFoundError, version
|
|
||||||
|
|
||||||
try:
|
|
||||||
__version__ = version("mcnoaa-tides")
|
|
||||||
except PackageNotFoundError:
|
|
||||||
__version__ = "0.0.0-dev"
|
|
||||||
@ -1,33 +0,0 @@
|
|||||||
"""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]"
|
|
||||||
)
|
|
||||||
@ -1,387 +0,0 @@
|
|||||||
"""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)
|
|
||||||
@ -1,223 +0,0 @@
|
|||||||
"""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)
|
|
||||||
@ -1,213 +0,0 @@
|
|||||||
"""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]
|
|
||||||
@ -1,49 +0,0 @@
|
|||||||
"""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")
|
|
||||||
@ -1,78 +0,0 @@
|
|||||||
"""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.
|
|
||||||
"""
|
|
||||||
@ -1,43 +0,0 @@
|
|||||||
"""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,
|
|
||||||
)
|
|
||||||
@ -1,61 +0,0 @@
|
|||||||
"""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()
|
|
||||||
@ -1,181 +0,0 @@
|
|||||||
"""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
|
|
||||||
@ -1,77 +0,0 @@
|
|||||||
"""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
|
|
||||||
@ -1,55 +0,0 @@
|
|||||||
"""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,
|
|
||||||
)
|
|
||||||
@ -1,72 +0,0 @@
|
|||||||
"""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)
|
|
||||||
@ -1,69 +0,0 @@
|
|||||||
"""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,
|
|
||||||
)
|
|
||||||
@ -1,154 +0,0 @@
|
|||||||
"""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
|
|
||||||
@ -1,175 +0,0 @@
|
|||||||
"""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
|
|
||||||
@ -1,141 +0,0 @@
|
|||||||
"""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