solar-data-mcp-forecast 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- solar_data_mcp_forecast-0.1.0/.gitignore +20 -0
- solar_data_mcp_forecast-0.1.0/LICENSE +21 -0
- solar_data_mcp_forecast-0.1.0/PKG-INFO +67 -0
- solar_data_mcp_forecast-0.1.0/README.md +45 -0
- solar_data_mcp_forecast-0.1.0/pyproject.toml +43 -0
- solar_data_mcp_forecast-0.1.0/src/solar_mcp_forecast/__init__.py +3 -0
- solar_data_mcp_forecast-0.1.0/src/solar_mcp_forecast/predictor.py +72 -0
- solar_data_mcp_forecast-0.1.0/src/solar_mcp_forecast/py.typed +0 -0
- solar_data_mcp_forecast-0.1.0/src/solar_mcp_forecast/resources.py +34 -0
- solar_data_mcp_forecast-0.1.0/src/solar_mcp_forecast/server.py +161 -0
- solar_data_mcp_forecast-0.1.0/src/solar_mcp_forecast/tools/__init__.py +1 -0
- solar_data_mcp_forecast-0.1.0/src/solar_mcp_forecast/tools/compare_forecast_to_model.py +103 -0
- solar_data_mcp_forecast-0.1.0/src/solar_mcp_forecast/tools/forecast_generation.py +123 -0
- solar_data_mcp_forecast-0.1.0/tests/test_compare_forecast.py +81 -0
- solar_data_mcp_forecast-0.1.0/tests/test_forecast_generation.py +110 -0
- solar_data_mcp_forecast-0.1.0/tests/test_forecast_server.py +98 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Logan Bernard
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: solar-data-mcp-forecast
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: MCP server for solar generation forecasts via the open Quartz Solar Forecast model (Open Climate Fix)
|
|
5
|
+
Project-URL: Homepage, https://github.com/hoodsy/solar-data-mcp
|
|
6
|
+
Author: Logan Bernard
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Keywords: forecast,mcp,open-climate-fix,quartz,solar
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Classifier: Topic :: Scientific/Engineering
|
|
15
|
+
Requires-Python: >=3.11
|
|
16
|
+
Requires-Dist: httpx<1,>=0.27
|
|
17
|
+
Requires-Dist: mcp<2,>=1.26
|
|
18
|
+
Requires-Dist: pydantic<3,>=2.7
|
|
19
|
+
Requires-Dist: solar-data-mcp-core<0.2,>=0.1
|
|
20
|
+
Requires-Dist: solar-data-mcp-nrel<0.2,>=0.1
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
|
|
23
|
+
# solar-data-mcp-forecast
|
|
24
|
+
|
|
25
|
+
MCP server wrapping the open [Quartz Solar Forecast](https://github.com/openclimatefix/quartz-solar-forecast)
|
|
26
|
+
model (Open Climate Fix) — the forecast domain of
|
|
27
|
+
[solar-data-mcp](https://github.com/hoodsy/solar-data-mcp). No API key needed.
|
|
28
|
+
|
|
29
|
+
| Tool | Answers |
|
|
30
|
+
|---|---|
|
|
31
|
+
| `forecast_generation` | "What will my array generate tomorrow?" (hourly, ≤48 h) |
|
|
32
|
+
| `compare_forecast_to_model` | "Is today unusually sunny?" (forecast vs PVWatts typical-year) |
|
|
33
|
+
|
|
34
|
+
Run standalone: `uvx --from solar-data-mcp-forecast solar-forecast-mcp` — but see the
|
|
35
|
+
model-install note below; most users want the combined
|
|
36
|
+
[`solar-data-mcp`](https://github.com/hoodsy/solar-data-mcp/blob/main/packages/solar-data-mcp/README.md)
|
|
37
|
+
server, which adds the other three domains plus the skill and report layer.
|
|
38
|
+
|
|
39
|
+
## Installing the model
|
|
40
|
+
|
|
41
|
+
`quartz-solar-forecast` pins `pydantic==2.6.2`, which conflicts with the MCP SDK,
|
|
42
|
+
so it is not a declared dependency. Install it alongside (it runs fine on newer
|
|
43
|
+
pydantic; the pin is conservative):
|
|
44
|
+
|
|
45
|
+
```console
|
|
46
|
+
$ pip install solar-data-mcp-forecast
|
|
47
|
+
$ pip install --no-deps quartz-solar-forecast
|
|
48
|
+
$ pip install pv-site-prediction xarray xgboost openmeteo-requests requests-cache retry-requests
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Without the model installed, the server still starts and its tools return an
|
|
52
|
+
error containing these instructions.
|
|
53
|
+
|
|
54
|
+
## Using forecasts with the combined `solar-data-mcp` server
|
|
55
|
+
|
|
56
|
+
An ephemeral `uvx` environment cannot hold the side-install above. Use a
|
|
57
|
+
persistent venv and point your agent at its entry point:
|
|
58
|
+
|
|
59
|
+
```console
|
|
60
|
+
$ python3 -m venv ~/.venvs/solar-data-mcp
|
|
61
|
+
$ ~/.venvs/solar-data-mcp/bin/pip install solar-data-mcp
|
|
62
|
+
$ ~/.venvs/solar-data-mcp/bin/pip install --no-deps quartz-solar-forecast
|
|
63
|
+
$ ~/.venvs/solar-data-mcp/bin/pip install pv-site-prediction xarray xgboost openmeteo-requests requests-cache retry-requests
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Then in the agent config, replace `"command": "uvx", "args": ["solar-data-mcp"]`
|
|
67
|
+
with `"command": "~/.venvs/solar-data-mcp/bin/solar-data-mcp"`.
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# solar-data-mcp-forecast
|
|
2
|
+
|
|
3
|
+
MCP server wrapping the open [Quartz Solar Forecast](https://github.com/openclimatefix/quartz-solar-forecast)
|
|
4
|
+
model (Open Climate Fix) — the forecast domain of
|
|
5
|
+
[solar-data-mcp](https://github.com/hoodsy/solar-data-mcp). No API key needed.
|
|
6
|
+
|
|
7
|
+
| Tool | Answers |
|
|
8
|
+
|---|---|
|
|
9
|
+
| `forecast_generation` | "What will my array generate tomorrow?" (hourly, ≤48 h) |
|
|
10
|
+
| `compare_forecast_to_model` | "Is today unusually sunny?" (forecast vs PVWatts typical-year) |
|
|
11
|
+
|
|
12
|
+
Run standalone: `uvx --from solar-data-mcp-forecast solar-forecast-mcp` — but see the
|
|
13
|
+
model-install note below; most users want the combined
|
|
14
|
+
[`solar-data-mcp`](https://github.com/hoodsy/solar-data-mcp/blob/main/packages/solar-data-mcp/README.md)
|
|
15
|
+
server, which adds the other three domains plus the skill and report layer.
|
|
16
|
+
|
|
17
|
+
## Installing the model
|
|
18
|
+
|
|
19
|
+
`quartz-solar-forecast` pins `pydantic==2.6.2`, which conflicts with the MCP SDK,
|
|
20
|
+
so it is not a declared dependency. Install it alongside (it runs fine on newer
|
|
21
|
+
pydantic; the pin is conservative):
|
|
22
|
+
|
|
23
|
+
```console
|
|
24
|
+
$ pip install solar-data-mcp-forecast
|
|
25
|
+
$ pip install --no-deps quartz-solar-forecast
|
|
26
|
+
$ pip install pv-site-prediction xarray xgboost openmeteo-requests requests-cache retry-requests
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Without the model installed, the server still starts and its tools return an
|
|
30
|
+
error containing these instructions.
|
|
31
|
+
|
|
32
|
+
## Using forecasts with the combined `solar-data-mcp` server
|
|
33
|
+
|
|
34
|
+
An ephemeral `uvx` environment cannot hold the side-install above. Use a
|
|
35
|
+
persistent venv and point your agent at its entry point:
|
|
36
|
+
|
|
37
|
+
```console
|
|
38
|
+
$ python3 -m venv ~/.venvs/solar-data-mcp
|
|
39
|
+
$ ~/.venvs/solar-data-mcp/bin/pip install solar-data-mcp
|
|
40
|
+
$ ~/.venvs/solar-data-mcp/bin/pip install --no-deps quartz-solar-forecast
|
|
41
|
+
$ ~/.venvs/solar-data-mcp/bin/pip install pv-site-prediction xarray xgboost openmeteo-requests requests-cache retry-requests
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Then in the agent config, replace `"command": "uvx", "args": ["solar-data-mcp"]`
|
|
45
|
+
with `"command": "~/.venvs/solar-data-mcp/bin/solar-data-mcp"`.
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "solar-data-mcp-forecast"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "MCP server for solar generation forecasts via the open Quartz Solar Forecast model (Open Climate Fix)"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
requires-python = ">=3.11"
|
|
8
|
+
authors = [{ name = "Logan Bernard" }]
|
|
9
|
+
keywords = ["solar", "mcp", "forecast", "quartz", "open-climate-fix"]
|
|
10
|
+
classifiers = [
|
|
11
|
+
"Development Status :: 4 - Beta",
|
|
12
|
+
"Intended Audience :: Developers",
|
|
13
|
+
"Programming Language :: Python :: 3.11",
|
|
14
|
+
"Programming Language :: Python :: 3.12",
|
|
15
|
+
"Topic :: Scientific/Engineering",
|
|
16
|
+
]
|
|
17
|
+
# quartz-solar-forecast is deliberately NOT a dependency: it pins pydantic==2.6.2,
|
|
18
|
+
# which conflicts with the MCP SDK. See README for the documented install path;
|
|
19
|
+
# the server starts without it and returns actionable errors from forecast tools.
|
|
20
|
+
dependencies = [
|
|
21
|
+
"solar-data-mcp-core>=0.1,<0.2",
|
|
22
|
+
"solar-data-mcp-nrel>=0.1,<0.2",
|
|
23
|
+
"mcp>=1.26,<2",
|
|
24
|
+
"httpx>=0.27,<1",
|
|
25
|
+
"pydantic>=2.7,<3",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
[project.urls]
|
|
29
|
+
Homepage = "https://github.com/hoodsy/solar-data-mcp"
|
|
30
|
+
|
|
31
|
+
[project.scripts]
|
|
32
|
+
solar-forecast-mcp = "solar_mcp_forecast.server:main"
|
|
33
|
+
|
|
34
|
+
[tool.uv.sources]
|
|
35
|
+
solar-data-mcp-core = { workspace = true }
|
|
36
|
+
solar-data-mcp-nrel = { workspace = true }
|
|
37
|
+
|
|
38
|
+
[build-system]
|
|
39
|
+
requires = ["hatchling"]
|
|
40
|
+
build-backend = "hatchling.build"
|
|
41
|
+
|
|
42
|
+
[tool.hatch.build.targets.wheel]
|
|
43
|
+
packages = ["src/solar_mcp_forecast"]
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""The predictor seam: everything model-specific behind one callable type.
|
|
2
|
+
|
|
3
|
+
Tools depend on `Predictor`, not on quartz — tests stub it, CI never loads the
|
|
4
|
+
ML stack, and the quartz import failure turns into an actionable message
|
|
5
|
+
instead of a broken server.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from collections.abc import Callable
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from datetime import UTC, datetime
|
|
11
|
+
|
|
12
|
+
from solar_mcp_core.errors import SolarMCPError
|
|
13
|
+
|
|
14
|
+
QUARTZ_URL = "https://github.com/openclimatefix/quartz-solar-forecast"
|
|
15
|
+
QUARTZ_LICENSE = "MIT (Open Climate Fix); open NWP inputs, no API key"
|
|
16
|
+
|
|
17
|
+
INSTALL_HINT = (
|
|
18
|
+
"quartz-solar-forecast is not installed. It pins pydantic==2.6.2 (conflicts "
|
|
19
|
+
"with the MCP SDK) so it is not auto-installed; add it alongside with: "
|
|
20
|
+
"pip install --no-deps quartz-solar-forecast && pip install "
|
|
21
|
+
"pv-site-prediction xarray xgboost openmeteo-requests requests-cache "
|
|
22
|
+
"retry-requests (see the solar-data-mcp-forecast README)"
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class ForecastRequest:
|
|
28
|
+
lat: float
|
|
29
|
+
lon: float
|
|
30
|
+
capacity_kw: float
|
|
31
|
+
tilt_deg: float
|
|
32
|
+
azimuth_deg: float
|
|
33
|
+
horizon_hours: int
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True)
|
|
37
|
+
class ForecastPoint:
|
|
38
|
+
time: str # ISO 8601 UTC, hourly steps
|
|
39
|
+
power_kw: float
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
# Synchronous by design — model inference is CPU-bound; tools run it in a thread.
|
|
43
|
+
Predictor = Callable[[ForecastRequest], list[ForecastPoint]]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def quartz_predictor(request: ForecastRequest) -> list[ForecastPoint]:
|
|
47
|
+
"""Run the real Quartz model. Imported lazily; see INSTALL_HINT."""
|
|
48
|
+
try:
|
|
49
|
+
from quartz_solar_forecast.forecast import run_forecast
|
|
50
|
+
from quartz_solar_forecast.pydantic_models import PVSite
|
|
51
|
+
except ImportError as exc:
|
|
52
|
+
raise SolarMCPError(INSTALL_HINT) from exc
|
|
53
|
+
|
|
54
|
+
site = PVSite(
|
|
55
|
+
latitude=request.lat,
|
|
56
|
+
longitude=request.lon,
|
|
57
|
+
capacity_kwp=request.capacity_kw,
|
|
58
|
+
tilt=request.tilt_deg,
|
|
59
|
+
orientation=request.azimuth_deg,
|
|
60
|
+
)
|
|
61
|
+
frame = run_forecast(site=site, ts=datetime.now(tz=UTC).replace(tzinfo=None))
|
|
62
|
+
# Contract: hourly points. Quartz emits sub-hourly steps; resample to 1h means.
|
|
63
|
+
hourly = frame.resample("1h").mean().head(request.horizon_hours)
|
|
64
|
+
points: list[ForecastPoint] = []
|
|
65
|
+
for timestamp, row in hourly.iterrows():
|
|
66
|
+
points.append(
|
|
67
|
+
ForecastPoint(
|
|
68
|
+
time=timestamp.strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
69
|
+
power_kw=float(row["power_kw"]),
|
|
70
|
+
)
|
|
71
|
+
)
|
|
72
|
+
return points
|
|
File without changes
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""MCP resources: provenance agents can cite (license, coverage)."""
|
|
2
|
+
|
|
3
|
+
from mcp.server.fastmcp import FastMCP
|
|
4
|
+
|
|
5
|
+
QUARTZ_LICENSE_TEXT = """\
|
|
6
|
+
Quartz Solar Forecast — Open Climate Fix.
|
|
7
|
+
|
|
8
|
+
- Model and code: MIT license, https://github.com/openclimatefix/quartz-solar-forecast
|
|
9
|
+
- Inputs: open numerical weather prediction data; no API key, no live PV feed.
|
|
10
|
+
- Cite "Open Climate Fix, Quartz Solar Forecast" with the run timestamp.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
COVERAGE = """\
|
|
14
|
+
Coverage notes for the solar-forecast server.
|
|
15
|
+
|
|
16
|
+
- Forecasts are cold-start (weather-only) site-level estimates, horizon <= 48h,
|
|
17
|
+
global NWP coverage; accuracy is best where NWP models are strong (US/EU).
|
|
18
|
+
- Screening-grade output: planning and "is today unusual" questions — not for
|
|
19
|
+
grid settlement, bidding, or contractual commitments.
|
|
20
|
+
- The comparison baseline is PVWatts TMY (typical meteorological year) spread
|
|
21
|
+
uniformly over the month's hours; see each result's assumptions.
|
|
22
|
+
- The quartz-solar-forecast package must be installed separately (its pydantic
|
|
23
|
+
pin conflicts with the MCP SDK — see the package README for the exact steps).
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def register(mcp: FastMCP) -> None:
|
|
28
|
+
@mcp.resource("source://quartz/license", title="Quartz model license & citation")
|
|
29
|
+
def quartz_license() -> str:
|
|
30
|
+
return QUARTZ_LICENSE_TEXT
|
|
31
|
+
|
|
32
|
+
@mcp.resource("source://solar-forecast/coverage", title="Forecast coverage & limits")
|
|
33
|
+
def coverage() -> str:
|
|
34
|
+
return COVERAGE
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""FastMCP server exposing the solar-forecast tools over stdio.
|
|
2
|
+
|
|
3
|
+
The Quartz model is behind the Predictor seam: the server starts without it
|
|
4
|
+
installed, and forecast tools fail with install instructions rather than
|
|
5
|
+
breaking the whole server. compare_forecast_to_model additionally shares one
|
|
6
|
+
NREL client for the PVWatts TMY baseline.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from collections.abc import AsyncIterator, Callable
|
|
10
|
+
from contextlib import asynccontextmanager
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from typing import Protocol, TypeVar
|
|
13
|
+
|
|
14
|
+
from mcp.server.fastmcp import Context, FastMCP
|
|
15
|
+
from mcp.server.session import ServerSession
|
|
16
|
+
from solar_mcp_core.config import NREL
|
|
17
|
+
from solar_mcp_core.envelope import ToolResult
|
|
18
|
+
from solar_mcp_core.http import SolarHttpClient, configure_debug_logging
|
|
19
|
+
|
|
20
|
+
from solar_mcp_forecast import resources
|
|
21
|
+
from solar_mcp_forecast.predictor import Predictor, quartz_predictor
|
|
22
|
+
from solar_mcp_forecast.tools.compare_forecast_to_model import (
|
|
23
|
+
compare_forecast_to_model as _compare,
|
|
24
|
+
)
|
|
25
|
+
from solar_mcp_forecast.tools.forecast_generation import forecast_generation as _forecast
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class AppContext:
|
|
30
|
+
predictor: Predictor
|
|
31
|
+
nrel: SolarHttpClient
|
|
32
|
+
|
|
33
|
+
async def aclose(self) -> None:
|
|
34
|
+
await self.nrel.aclose()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class ForecastDeps(Protocol):
|
|
38
|
+
"""Context fields the forecast tools read. Any hosting server's lifespan
|
|
39
|
+
context must provide these — this package's AppContext does, and so does
|
|
40
|
+
the solar-data-mcp umbrella's composite context."""
|
|
41
|
+
|
|
42
|
+
nrel: SolarHttpClient
|
|
43
|
+
|
|
44
|
+
# Read-only property, not a plain attribute: mypy treats Callable-typed
|
|
45
|
+
# dataclass fields as read-only, and a settable protocol member would
|
|
46
|
+
# reject every conforming AppContext.
|
|
47
|
+
@property
|
|
48
|
+
def predictor(self) -> Predictor: ...
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
DepsT = TypeVar("DepsT", bound=ForecastDeps)
|
|
52
|
+
|
|
53
|
+
ToolContext = Context[ServerSession, ForecastDeps]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def default_context() -> AppContext:
|
|
57
|
+
return AppContext(predictor=quartz_predictor, nrel=SolarHttpClient(NREL))
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def register_tools(mcp: FastMCP[DepsT]) -> None:
|
|
61
|
+
@mcp.tool()
|
|
62
|
+
async def forecast_generation(
|
|
63
|
+
ctx: ToolContext,
|
|
64
|
+
lat: float,
|
|
65
|
+
lon: float,
|
|
66
|
+
capacity_kw: float,
|
|
67
|
+
tilt_deg: float | None = None,
|
|
68
|
+
azimuth_deg: float | None = None,
|
|
69
|
+
horizon_hours: int | None = None,
|
|
70
|
+
) -> ToolResult:
|
|
71
|
+
"""Hourly generation forecast for the next hours (Quartz open model).
|
|
72
|
+
|
|
73
|
+
Use this for "how much will this system produce today/tomorrow".
|
|
74
|
+
Use nrel-solar's estimate_production for typical-year expectations,
|
|
75
|
+
and compare_forecast_to_model to judge whether the forecast is unusual.
|
|
76
|
+
Defaults: tilt = site latitude, azimuth 180 (south), horizon 48h —
|
|
77
|
+
each stated in assumptions. Max horizon 48 hours.
|
|
78
|
+
|
|
79
|
+
Example: forecast_generation(lat=39.74, lon=-105.18, capacity_kw=6)
|
|
80
|
+
-> hourly kW series, total_kwh, peak_kw.
|
|
81
|
+
|
|
82
|
+
Units: power in kW AC; energy in kWh; times ISO 8601 UTC.
|
|
83
|
+
"""
|
|
84
|
+
return await _forecast(
|
|
85
|
+
ctx.request_context.lifespan_context.predictor,
|
|
86
|
+
lat=lat,
|
|
87
|
+
lon=lon,
|
|
88
|
+
capacity_kw=capacity_kw,
|
|
89
|
+
tilt_deg=tilt_deg,
|
|
90
|
+
azimuth_deg=azimuth_deg,
|
|
91
|
+
horizon_hours=horizon_hours,
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
@mcp.tool()
|
|
95
|
+
async def compare_forecast_to_model(
|
|
96
|
+
ctx: ToolContext,
|
|
97
|
+
lat: float,
|
|
98
|
+
lon: float,
|
|
99
|
+
capacity_kw: float,
|
|
100
|
+
tilt_deg: float | None = None,
|
|
101
|
+
azimuth_deg: float | None = None,
|
|
102
|
+
horizon_hours: int | None = None,
|
|
103
|
+
) -> ToolResult:
|
|
104
|
+
"""Is the forecast unusual? Quartz forecast vs PVWatts typical-year rate.
|
|
105
|
+
|
|
106
|
+
Use this for "is today a good solar day here". Returns the forecast
|
|
107
|
+
total, the TMY-typical total for the same horizon, their ratio, and a
|
|
108
|
+
plain-language verdict. Horizons in multiples of 24h compare cleanest
|
|
109
|
+
(see assumptions for the uniform-spread simplification).
|
|
110
|
+
|
|
111
|
+
Example: compare_forecast_to_model(lat=39.74, lon=-105.18,
|
|
112
|
+
capacity_kw=6, horizon_hours=24) -> ratio_pct ~120 on a clear day.
|
|
113
|
+
|
|
114
|
+
Units: energies in kWh; ratio in percent.
|
|
115
|
+
"""
|
|
116
|
+
context = ctx.request_context.lifespan_context
|
|
117
|
+
return await _compare(
|
|
118
|
+
context.predictor,
|
|
119
|
+
context.nrel,
|
|
120
|
+
lat=lat,
|
|
121
|
+
lon=lon,
|
|
122
|
+
capacity_kw=capacity_kw,
|
|
123
|
+
tilt_deg=tilt_deg,
|
|
124
|
+
azimuth_deg=azimuth_deg,
|
|
125
|
+
horizon_hours=horizon_hours,
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def create_server(context_factory: Callable[[], AppContext] | None = None) -> FastMCP:
|
|
130
|
+
factory = context_factory if context_factory is not None else default_context
|
|
131
|
+
|
|
132
|
+
@asynccontextmanager
|
|
133
|
+
async def lifespan(_server: FastMCP) -> AsyncIterator[AppContext]:
|
|
134
|
+
context = factory()
|
|
135
|
+
try:
|
|
136
|
+
yield context
|
|
137
|
+
finally:
|
|
138
|
+
await context.aclose()
|
|
139
|
+
|
|
140
|
+
mcp: FastMCP[AppContext] = FastMCP(
|
|
141
|
+
"solar-forecast",
|
|
142
|
+
instructions=(
|
|
143
|
+
"Solar generation forecasts from the open Quartz model (Open Climate "
|
|
144
|
+
"Fix) — no API key. forecast_generation for the next <=48h; "
|
|
145
|
+
"compare_forecast_to_model for 'is today unusual?' against PVWatts "
|
|
146
|
+
"TMY. Screening-grade, not grid settlement."
|
|
147
|
+
),
|
|
148
|
+
lifespan=lifespan,
|
|
149
|
+
)
|
|
150
|
+
register_tools(mcp)
|
|
151
|
+
resources.register(mcp)
|
|
152
|
+
return mcp
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def main() -> None:
|
|
156
|
+
configure_debug_logging()
|
|
157
|
+
create_server().run()
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
if __name__ == "__main__":
|
|
161
|
+
main()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Tool implementations. Plain typed async functions; MCP shims live in server.py."""
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""compare_forecast_to_model: is the next-hours forecast unusual vs TMY typical?"""
|
|
2
|
+
|
|
3
|
+
import calendar
|
|
4
|
+
from datetime import UTC, datetime
|
|
5
|
+
|
|
6
|
+
from solar_mcp_core import units
|
|
7
|
+
from solar_mcp_core.envelope import ToolResult, audit_entry, composite_source_ref
|
|
8
|
+
from solar_mcp_core.http import SolarHttpClient
|
|
9
|
+
from solar_mcp_nrel.models import SystemSpec
|
|
10
|
+
from solar_mcp_nrel.tools.estimate_production import estimate_production
|
|
11
|
+
|
|
12
|
+
from solar_mcp_forecast.predictor import Predictor
|
|
13
|
+
from solar_mcp_forecast.tools.forecast_generation import forecast_generation
|
|
14
|
+
|
|
15
|
+
UNIFORM_ASSUMPTION = (
|
|
16
|
+
"TMY expectation spreads the month's typical production uniformly across "
|
|
17
|
+
"its hours; forecasts covering mostly daylight (or night) will skew high "
|
|
18
|
+
"(or low) against it — horizons in multiples of 24h compare cleanest"
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
async def compare_forecast_to_model(
|
|
23
|
+
predictor: Predictor,
|
|
24
|
+
nrel_client: SolarHttpClient,
|
|
25
|
+
*,
|
|
26
|
+
lat: float,
|
|
27
|
+
lon: float,
|
|
28
|
+
capacity_kw: float,
|
|
29
|
+
tilt_deg: float | None = None,
|
|
30
|
+
azimuth_deg: float | None = None,
|
|
31
|
+
horizon_hours: int | None = None,
|
|
32
|
+
) -> ToolResult:
|
|
33
|
+
forecast = await forecast_generation(
|
|
34
|
+
predictor,
|
|
35
|
+
lat=lat,
|
|
36
|
+
lon=lon,
|
|
37
|
+
capacity_kw=capacity_kw,
|
|
38
|
+
tilt_deg=tilt_deg,
|
|
39
|
+
azimuth_deg=azimuth_deg,
|
|
40
|
+
horizon_hours=horizon_hours,
|
|
41
|
+
)
|
|
42
|
+
horizon = int(forecast.data["horizon_hours"])
|
|
43
|
+
hours_returned = int(forecast.data["hours_returned"])
|
|
44
|
+
forecast_kwh = float(forecast.data["total_kwh"])
|
|
45
|
+
|
|
46
|
+
typical = await estimate_production(
|
|
47
|
+
nrel_client,
|
|
48
|
+
SystemSpec(lat=lat, lon=lon, tilt_deg=tilt_deg, azimuth_deg=azimuth_deg),
|
|
49
|
+
capacity_kw,
|
|
50
|
+
)
|
|
51
|
+
now = datetime.now(tz=UTC)
|
|
52
|
+
month_kwh = float(typical.data["ac_monthly"][now.month - 1])
|
|
53
|
+
days_in_month = calendar.monthrange(now.year, now.month)[1]
|
|
54
|
+
expected_kwh = round(month_kwh / (days_in_month * 24) * hours_returned, 2)
|
|
55
|
+
|
|
56
|
+
ratio_pct = round(forecast_kwh / expected_kwh * 100, 1) if expected_kwh > 0 else None
|
|
57
|
+
if ratio_pct is None:
|
|
58
|
+
verdict = "no typical-production baseline for this month"
|
|
59
|
+
elif ratio_pct >= 115:
|
|
60
|
+
verdict = f"unusually sunny: ~{ratio_pct - 100:.0f}% above a typical {now:%B}"
|
|
61
|
+
elif ratio_pct <= 85:
|
|
62
|
+
verdict = f"below typical: ~{100 - ratio_pct:.0f}% under a typical {now:%B}"
|
|
63
|
+
else:
|
|
64
|
+
verdict = f"close to typical for {now:%B}"
|
|
65
|
+
|
|
66
|
+
warnings = list(dict.fromkeys([*forecast.warnings, *typical.warnings]))
|
|
67
|
+
if hours_returned % 24 != 0:
|
|
68
|
+
warnings.append(
|
|
69
|
+
f"compared window of {hours_returned}h is not a whole number of days; "
|
|
70
|
+
"the daylight share skews the ratio (see assumptions)"
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
return ToolResult(
|
|
74
|
+
data={
|
|
75
|
+
"forecast_kwh": forecast_kwh,
|
|
76
|
+
"tmy_expected_kwh": expected_kwh,
|
|
77
|
+
"ratio_pct": ratio_pct,
|
|
78
|
+
"verdict": verdict,
|
|
79
|
+
"horizon_hours": horizon,
|
|
80
|
+
"month": f"{now:%Y-%m}",
|
|
81
|
+
"audit_trail": [
|
|
82
|
+
audit_entry("forecast", forecast.source),
|
|
83
|
+
audit_entry("tmy_baseline", typical.source),
|
|
84
|
+
],
|
|
85
|
+
},
|
|
86
|
+
units={
|
|
87
|
+
"forecast_kwh": units.KWH,
|
|
88
|
+
"tmy_expected_kwh": units.KWH,
|
|
89
|
+
"ratio_pct": units.PERCENT,
|
|
90
|
+
"verdict": units.LABEL,
|
|
91
|
+
"horizon_hours": units.HOURS,
|
|
92
|
+
"month": units.ISO_DATE,
|
|
93
|
+
"audit_trail[].component": units.LABEL,
|
|
94
|
+
"audit_trail[].retrieved_at": units.ISO_DATE,
|
|
95
|
+
},
|
|
96
|
+
source=composite_source_ref(),
|
|
97
|
+
assumptions=[
|
|
98
|
+
*forecast.assumptions,
|
|
99
|
+
*(f"tmy_baseline: {line}" for line in typical.assumptions),
|
|
100
|
+
UNIFORM_ASSUMPTION,
|
|
101
|
+
],
|
|
102
|
+
warnings=warnings,
|
|
103
|
+
)
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""forecast_generation: next-hours generation forecast from the open Quartz model."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
|
|
5
|
+
from solar_mcp_core import units
|
|
6
|
+
from solar_mcp_core.envelope import SourceRef, ToolResult, utc_now_iso
|
|
7
|
+
from solar_mcp_core.errors import BadInput, SolarMCPError, SourceUnavailable
|
|
8
|
+
from solar_mcp_core.validation import (
|
|
9
|
+
default_tilt_azimuth,
|
|
10
|
+
validate_capacity_kw,
|
|
11
|
+
validate_lat_lon,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
from solar_mcp_forecast.predictor import (
|
|
15
|
+
QUARTZ_LICENSE,
|
|
16
|
+
QUARTZ_URL,
|
|
17
|
+
ForecastRequest,
|
|
18
|
+
Predictor,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
MAX_HORIZON_HOURS = 48
|
|
22
|
+
OPEN_MODEL_CAVEAT = (
|
|
23
|
+
"Open-source ML forecast from public weather models (no live PV feed); "
|
|
24
|
+
"useful for planning, not for grid settlement or contractual commitments."
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def resolve_forecast_request(
|
|
29
|
+
*,
|
|
30
|
+
lat: float,
|
|
31
|
+
lon: float,
|
|
32
|
+
capacity_kw: float,
|
|
33
|
+
tilt_deg: float | None,
|
|
34
|
+
azimuth_deg: float | None,
|
|
35
|
+
horizon_hours: int | None,
|
|
36
|
+
) -> tuple[ForecastRequest, list[str], list[str]]:
|
|
37
|
+
"""Validate and default-fill, recording every injected default. Pure."""
|
|
38
|
+
validate_lat_lon(lat, lon)
|
|
39
|
+
validate_capacity_kw(capacity_kw)
|
|
40
|
+
tilt_deg, azimuth_deg, assumptions, warnings = default_tilt_azimuth(lat, tilt_deg, azimuth_deg)
|
|
41
|
+
if not 0 <= tilt_deg <= 90:
|
|
42
|
+
raise BadInput(field="tilt_deg", value=tilt_deg, allowed="0 to 90")
|
|
43
|
+
if not 0 <= azimuth_deg < 360:
|
|
44
|
+
raise BadInput(field="azimuth_deg", value=azimuth_deg, allowed="0 to <360")
|
|
45
|
+
if horizon_hours is None:
|
|
46
|
+
horizon_hours = MAX_HORIZON_HOURS
|
|
47
|
+
assumptions.append(f"horizon_hours not provided; defaulted to {MAX_HORIZON_HOURS}")
|
|
48
|
+
if not 1 <= horizon_hours <= MAX_HORIZON_HOURS:
|
|
49
|
+
raise BadInput(
|
|
50
|
+
field="horizon_hours", value=horizon_hours, allowed=f"1 to {MAX_HORIZON_HOURS}"
|
|
51
|
+
)
|
|
52
|
+
request = ForecastRequest(
|
|
53
|
+
lat=lat,
|
|
54
|
+
lon=lon,
|
|
55
|
+
capacity_kw=capacity_kw,
|
|
56
|
+
tilt_deg=tilt_deg,
|
|
57
|
+
azimuth_deg=azimuth_deg,
|
|
58
|
+
horizon_hours=horizon_hours,
|
|
59
|
+
)
|
|
60
|
+
return request, assumptions, warnings
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
async def forecast_generation(
|
|
64
|
+
predictor: Predictor,
|
|
65
|
+
*,
|
|
66
|
+
lat: float,
|
|
67
|
+
lon: float,
|
|
68
|
+
capacity_kw: float,
|
|
69
|
+
tilt_deg: float | None = None,
|
|
70
|
+
azimuth_deg: float | None = None,
|
|
71
|
+
horizon_hours: int | None = None,
|
|
72
|
+
) -> ToolResult:
|
|
73
|
+
request, assumptions, site_warnings = resolve_forecast_request(
|
|
74
|
+
lat=lat,
|
|
75
|
+
lon=lon,
|
|
76
|
+
capacity_kw=capacity_kw,
|
|
77
|
+
tilt_deg=tilt_deg,
|
|
78
|
+
azimuth_deg=azimuth_deg,
|
|
79
|
+
horizon_hours=horizon_hours,
|
|
80
|
+
)
|
|
81
|
+
try:
|
|
82
|
+
points = await asyncio.to_thread(predictor, request) # model inference is CPU-bound
|
|
83
|
+
except SolarMCPError:
|
|
84
|
+
raise
|
|
85
|
+
except Exception as exc: # NWP download / schema errors from the model stack
|
|
86
|
+
raise SourceUnavailable("quartz", f"{type(exc).__name__}: {exc}") from exc
|
|
87
|
+
|
|
88
|
+
warnings = [*site_warnings, OPEN_MODEL_CAVEAT]
|
|
89
|
+
if len(points) < request.horizon_hours:
|
|
90
|
+
warnings.append(
|
|
91
|
+
f"model returned {len(points)} of {request.horizon_hours} requested hours; "
|
|
92
|
+
"totals cover the returned hours only"
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
total_kwh = round(sum(p.power_kw for p in points), 2) # hourly points -> kW*1h
|
|
96
|
+
peak = max(points, key=lambda p: p.power_kw) if points else None
|
|
97
|
+
return ToolResult(
|
|
98
|
+
data={
|
|
99
|
+
"series": [{"time": p.time, "power_kw": round(p.power_kw, 3)} for p in points],
|
|
100
|
+
"total_kwh": total_kwh,
|
|
101
|
+
"peak_kw": round(peak.power_kw, 3) if peak else 0.0,
|
|
102
|
+
"peak_time": peak.time if peak else None,
|
|
103
|
+
"horizon_hours": request.horizon_hours,
|
|
104
|
+
"hours_returned": len(points),
|
|
105
|
+
},
|
|
106
|
+
units={
|
|
107
|
+
"series[].time": units.ISO_DATE,
|
|
108
|
+
"series[].power_kw": units.KW_AC,
|
|
109
|
+
"total_kwh": units.KWH,
|
|
110
|
+
"peak_kw": units.KW_AC,
|
|
111
|
+
"peak_time": units.ISO_DATE,
|
|
112
|
+
"horizon_hours": units.HOURS,
|
|
113
|
+
"hours_returned": units.HOURS,
|
|
114
|
+
},
|
|
115
|
+
source=SourceRef(
|
|
116
|
+
name="Quartz Solar Forecast (Open Climate Fix)",
|
|
117
|
+
url=QUARTZ_URL,
|
|
118
|
+
retrieved_at=utc_now_iso(),
|
|
119
|
+
license=QUARTZ_LICENSE,
|
|
120
|
+
),
|
|
121
|
+
assumptions=[*assumptions, "cold-start forecast from NWP weather only (no live PV feed)"],
|
|
122
|
+
warnings=warnings,
|
|
123
|
+
)
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import calendar
|
|
2
|
+
from datetime import UTC, datetime
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
import pytest
|
|
7
|
+
from solar_mcp_core.config import NREL
|
|
8
|
+
from solar_mcp_core.http import SolarHttpClient
|
|
9
|
+
from solar_mcp_forecast.predictor import ForecastPoint, ForecastRequest, Predictor
|
|
10
|
+
from solar_mcp_forecast.tools.compare_forecast_to_model import compare_forecast_to_model
|
|
11
|
+
|
|
12
|
+
from conftest import assert_envelope, build_client
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def flat_predictor(power_kw: float) -> Predictor:
|
|
16
|
+
def predict(request: ForecastRequest) -> list[ForecastPoint]:
|
|
17
|
+
return [
|
|
18
|
+
ForecastPoint(time=f"2026-07-05T{hour:02d}:00:00Z", power_kw=power_kw)
|
|
19
|
+
for hour in range(request.horizon_hours)
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
return predict
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def nrel_client_with_flat_months(
|
|
26
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
27
|
+
) -> SolarHttpClient:
|
|
28
|
+
"""PVWatts stub tuned to exactly 1 kWh per hour in every calendar month."""
|
|
29
|
+
monkeypatch.setenv("NREL_API_KEY", "TESTKEY")
|
|
30
|
+
year = datetime.now(tz=UTC).year
|
|
31
|
+
ac_monthly = [calendar.monthrange(year, month)[1] * 24.0 for month in range(1, 13)]
|
|
32
|
+
body = {
|
|
33
|
+
"errors": [],
|
|
34
|
+
"warnings": [],
|
|
35
|
+
"station_info": {"lat": 39.7, "lon": -105.2},
|
|
36
|
+
"outputs": {
|
|
37
|
+
"ac_annual": sum(ac_monthly),
|
|
38
|
+
"ac_monthly": ac_monthly,
|
|
39
|
+
"solrad_annual": 4.8,
|
|
40
|
+
"capacity_factor": 16.4,
|
|
41
|
+
},
|
|
42
|
+
}
|
|
43
|
+
return build_client(NREL, lambda request: httpx.Response(200, json=body), tmp_path)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@pytest.mark.anyio
|
|
47
|
+
async def test_sunny_forecast_flagged_above_typical(
|
|
48
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
49
|
+
) -> None:
|
|
50
|
+
client = nrel_client_with_flat_months(tmp_path, monkeypatch)
|
|
51
|
+
result = await compare_forecast_to_model(
|
|
52
|
+
flat_predictor(1.25), client, lat=39.74, lon=-105.18, capacity_kw=6.0, horizon_hours=24
|
|
53
|
+
)
|
|
54
|
+
assert_envelope(result)
|
|
55
|
+
assert result.data["tmy_expected_kwh"] == pytest.approx(24.0)
|
|
56
|
+
assert result.data["forecast_kwh"] == pytest.approx(30.0)
|
|
57
|
+
assert result.data["ratio_pct"] == pytest.approx(125.0)
|
|
58
|
+
assert "unusually sunny" in result.data["verdict"]
|
|
59
|
+
assert not any("not a whole number of days" in w for w in result.warnings)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@pytest.mark.anyio
|
|
63
|
+
async def test_cloudy_forecast_flagged_below_typical(
|
|
64
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
65
|
+
) -> None:
|
|
66
|
+
client = nrel_client_with_flat_months(tmp_path, monkeypatch)
|
|
67
|
+
result = await compare_forecast_to_model(
|
|
68
|
+
flat_predictor(0.5), client, lat=39.74, lon=-105.18, capacity_kw=6.0, horizon_hours=24
|
|
69
|
+
)
|
|
70
|
+
assert result.data["ratio_pct"] == pytest.approx(50.0)
|
|
71
|
+
assert "below typical" in result.data["verdict"]
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@pytest.mark.anyio
|
|
75
|
+
async def test_partial_day_horizon_warns(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
76
|
+
client = nrel_client_with_flat_months(tmp_path, monkeypatch)
|
|
77
|
+
result = await compare_forecast_to_model(
|
|
78
|
+
flat_predictor(1.0), client, lat=39.74, lon=-105.18, capacity_kw=6.0, horizon_hours=30
|
|
79
|
+
)
|
|
80
|
+
assert any("not a whole number of days" in w for w in result.warnings)
|
|
81
|
+
assert any("uniformly" in a for a in result.assumptions)
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
from solar_mcp_core.errors import BadInput
|
|
3
|
+
from solar_mcp_forecast.predictor import ForecastPoint, ForecastRequest
|
|
4
|
+
from solar_mcp_forecast.tools.forecast_generation import (
|
|
5
|
+
forecast_generation,
|
|
6
|
+
resolve_forecast_request,
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
from conftest import assert_envelope
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def stub_predictor(request: ForecastRequest) -> list[ForecastPoint]:
|
|
13
|
+
"""Deterministic hourly ramp: 0, 2, 1 kW then zeros to the horizon."""
|
|
14
|
+
shape = [0.0, 2.0, 1.0]
|
|
15
|
+
points = []
|
|
16
|
+
for hour in range(request.horizon_hours):
|
|
17
|
+
power = shape[hour] if hour < len(shape) else 0.0
|
|
18
|
+
points.append(ForecastPoint(time=f"2026-07-05T{hour:02d}:00:00Z", power_kw=power))
|
|
19
|
+
return points
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class TestResolveForecastRequest:
|
|
23
|
+
def test_defaults_are_recorded(self) -> None:
|
|
24
|
+
request, assumptions, _warnings = resolve_forecast_request(
|
|
25
|
+
lat=39.74,
|
|
26
|
+
lon=-105.18,
|
|
27
|
+
capacity_kw=6.0,
|
|
28
|
+
tilt_deg=None,
|
|
29
|
+
azimuth_deg=None,
|
|
30
|
+
horizon_hours=None,
|
|
31
|
+
)
|
|
32
|
+
assert request.tilt_deg == pytest.approx(39.74)
|
|
33
|
+
assert request.azimuth_deg == 180.0
|
|
34
|
+
assert request.horizon_hours == 48
|
|
35
|
+
text = " ".join(assumptions)
|
|
36
|
+
for expected in ("tilt_deg", "azimuth_deg", "horizon_hours"):
|
|
37
|
+
assert expected in text
|
|
38
|
+
|
|
39
|
+
def test_explicit_values_produce_no_assumptions(self) -> None:
|
|
40
|
+
_, assumptions, _warnings = resolve_forecast_request(
|
|
41
|
+
lat=39.74,
|
|
42
|
+
lon=-105.18,
|
|
43
|
+
capacity_kw=6.0,
|
|
44
|
+
tilt_deg=25.0,
|
|
45
|
+
azimuth_deg=180.0,
|
|
46
|
+
horizon_hours=24,
|
|
47
|
+
)
|
|
48
|
+
assert assumptions == []
|
|
49
|
+
|
|
50
|
+
@pytest.mark.parametrize(
|
|
51
|
+
("field", "kwargs"),
|
|
52
|
+
[
|
|
53
|
+
("lat", {"lat": 91.0}),
|
|
54
|
+
("capacity_kw", {"capacity_kw": 0.01}),
|
|
55
|
+
("tilt_deg", {"tilt_deg": 95.0}),
|
|
56
|
+
("azimuth_deg", {"azimuth_deg": 360.0}),
|
|
57
|
+
("horizon_hours", {"horizon_hours": 49}),
|
|
58
|
+
("horizon_hours", {"horizon_hours": 0}),
|
|
59
|
+
],
|
|
60
|
+
)
|
|
61
|
+
def test_out_of_range_rejected(self, field: str, kwargs: dict[str, float]) -> None:
|
|
62
|
+
base: dict[str, object] = {
|
|
63
|
+
"lat": 39.74,
|
|
64
|
+
"lon": -105.18,
|
|
65
|
+
"capacity_kw": 6.0,
|
|
66
|
+
"tilt_deg": 25.0,
|
|
67
|
+
"azimuth_deg": 180.0,
|
|
68
|
+
"horizon_hours": 24,
|
|
69
|
+
}
|
|
70
|
+
base.update(kwargs)
|
|
71
|
+
with pytest.raises(BadInput) as excinfo:
|
|
72
|
+
resolve_forecast_request(**base) # type: ignore[arg-type]
|
|
73
|
+
assert excinfo.value.field == field
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@pytest.mark.anyio
|
|
77
|
+
async def test_forecast_generation_totals_and_peak() -> None:
|
|
78
|
+
result = await forecast_generation(
|
|
79
|
+
stub_predictor, lat=39.74, lon=-105.18, capacity_kw=6.0, horizon_hours=6
|
|
80
|
+
)
|
|
81
|
+
assert_envelope(result)
|
|
82
|
+
assert result.data["total_kwh"] == pytest.approx(3.0) # 0 + 2 + 1 kW over hourly steps
|
|
83
|
+
assert result.data["peak_kw"] == pytest.approx(2.0)
|
|
84
|
+
assert result.data["peak_time"] == "2026-07-05T01:00:00Z"
|
|
85
|
+
assert len(result.data["series"]) == 6
|
|
86
|
+
assert any("Open-source ML forecast" in w for w in result.warnings)
|
|
87
|
+
assert result.source.name.startswith("Quartz")
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@pytest.mark.anyio
|
|
91
|
+
async def test_predictor_crash_maps_to_source_unavailable() -> None:
|
|
92
|
+
from solar_mcp_core.errors import SourceUnavailable
|
|
93
|
+
|
|
94
|
+
def broken(request: ForecastRequest) -> list[ForecastPoint]:
|
|
95
|
+
raise RuntimeError("open-meteo download failed")
|
|
96
|
+
|
|
97
|
+
with pytest.raises(SourceUnavailable, match="RuntimeError"):
|
|
98
|
+
await forecast_generation(broken, lat=39.74, lon=-105.18, capacity_kw=6.0)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@pytest.mark.anyio
|
|
102
|
+
async def test_shortfall_hours_are_reported() -> None:
|
|
103
|
+
def short(request: ForecastRequest) -> list[ForecastPoint]:
|
|
104
|
+
return [ForecastPoint(time="2026-07-05T00:00:00Z", power_kw=1.0)]
|
|
105
|
+
|
|
106
|
+
result = await forecast_generation(
|
|
107
|
+
short, lat=39.74, lon=-105.18, capacity_kw=6.0, horizon_hours=24
|
|
108
|
+
)
|
|
109
|
+
assert result.data["hours_returned"] == 1
|
|
110
|
+
assert any("1 of 24 requested hours" in w for w in result.warnings)
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Server-level tests over an in-memory MCP session (no subprocess, no network)."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import AsyncIterator, Callable
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
from mcp.shared.memory import create_connected_server_and_client_session
|
|
7
|
+
from mcp.types import TextContent
|
|
8
|
+
from solar_mcp_core.config import NREL, SourceConfig
|
|
9
|
+
from solar_mcp_core.errors import SolarMCPError
|
|
10
|
+
from solar_mcp_core.http import SolarHttpClient
|
|
11
|
+
from solar_mcp_forecast.predictor import (
|
|
12
|
+
INSTALL_HINT,
|
|
13
|
+
ForecastPoint,
|
|
14
|
+
ForecastRequest,
|
|
15
|
+
quartz_predictor,
|
|
16
|
+
)
|
|
17
|
+
from solar_mcp_forecast.server import AppContext, create_server
|
|
18
|
+
|
|
19
|
+
from conftest import assert_tool_docs
|
|
20
|
+
|
|
21
|
+
ClientFor = Callable[[SourceConfig], SolarHttpClient]
|
|
22
|
+
|
|
23
|
+
EXPECTED_TOOLS = {"forecast_generation", "compare_forecast_to_model"}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def stub_predictor(request: ForecastRequest) -> list[ForecastPoint]:
|
|
27
|
+
return [
|
|
28
|
+
ForecastPoint(time=f"2026-07-05T{hour:02d}:00:00Z", power_kw=1.0)
|
|
29
|
+
for hour in range(request.horizon_hours)
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@pytest.fixture
|
|
34
|
+
async def session(client_for: ClientFor) -> AsyncIterator[object]:
|
|
35
|
+
def context() -> AppContext:
|
|
36
|
+
return AppContext(predictor=stub_predictor, nrel=client_for(NREL))
|
|
37
|
+
|
|
38
|
+
server = create_server(context_factory=context)
|
|
39
|
+
async with create_connected_server_and_client_session(
|
|
40
|
+
server._mcp_server, raise_exceptions=True
|
|
41
|
+
) as client_session:
|
|
42
|
+
yield client_session
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@pytest.mark.anyio
|
|
46
|
+
async def test_lists_both_tools_with_docs(session) -> None: # type: ignore[no-untyped-def]
|
|
47
|
+
tools = await session.list_tools()
|
|
48
|
+
assert {tool.name for tool in tools.tools} == EXPECTED_TOOLS
|
|
49
|
+
assert_tool_docs(tools.tools)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@pytest.mark.anyio
|
|
53
|
+
async def test_forecast_over_mcp(session) -> None: # type: ignore[no-untyped-def]
|
|
54
|
+
result = await session.call_tool(
|
|
55
|
+
"forecast_generation",
|
|
56
|
+
{"lat": 39.74, "lon": -105.18, "capacity_kw": 6.0, "horizon_hours": 12},
|
|
57
|
+
)
|
|
58
|
+
assert not result.isError
|
|
59
|
+
structured = result.structuredContent
|
|
60
|
+
assert structured is not None
|
|
61
|
+
for key in ("data", "units", "source", "assumptions", "warnings"):
|
|
62
|
+
assert key in structured
|
|
63
|
+
assert structured["data"]["total_kwh"] == pytest.approx(12.0)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@pytest.mark.anyio
|
|
67
|
+
async def test_bad_horizon_reported_not_crash(session) -> None: # type: ignore[no-untyped-def]
|
|
68
|
+
result = await session.call_tool(
|
|
69
|
+
"forecast_generation",
|
|
70
|
+
{"lat": 39.74, "lon": -105.18, "capacity_kw": 6.0, "horizon_hours": 99},
|
|
71
|
+
)
|
|
72
|
+
assert result.isError
|
|
73
|
+
assert isinstance(result.content[0], TextContent)
|
|
74
|
+
assert "horizon_hours" in result.content[0].text
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@pytest.mark.anyio
|
|
78
|
+
async def test_resources_exposed(session) -> None: # type: ignore[no-untyped-def]
|
|
79
|
+
resources = await session.list_resources()
|
|
80
|
+
uris = {str(resource.uri) for resource in resources.resources}
|
|
81
|
+
assert {"source://quartz/license", "source://solar-forecast/coverage"} <= uris
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def test_quartz_predictor_missing_is_actionable() -> None:
|
|
85
|
+
"""quartz-solar-forecast is not installed in this workspace (pydantic pin
|
|
86
|
+
conflict) — the predictor must fail with install instructions, not a bare
|
|
87
|
+
ImportError."""
|
|
88
|
+
request = ForecastRequest(
|
|
89
|
+
lat=39.74,
|
|
90
|
+
lon=-105.18,
|
|
91
|
+
capacity_kw=6.0,
|
|
92
|
+
tilt_deg=25.0,
|
|
93
|
+
azimuth_deg=180.0,
|
|
94
|
+
horizon_hours=4,
|
|
95
|
+
)
|
|
96
|
+
with pytest.raises(SolarMCPError) as excinfo:
|
|
97
|
+
quartz_predictor(request)
|
|
98
|
+
assert str(excinfo.value) == INSTALL_HINT
|