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.
@@ -0,0 +1,20 @@
1
+ # Environments & secrets
2
+ .env
3
+ .venv/
4
+
5
+ # Python
6
+ __pycache__/
7
+ *.py[cod]
8
+ *.egg-info/
9
+ dist/
10
+ build/
11
+
12
+ # Tooling
13
+ .pytest_cache/
14
+ .mypy_cache/
15
+ .ruff_cache/
16
+ .coverage
17
+ coverage.xml
18
+
19
+ # OS
20
+ .DS_Store
@@ -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,3 @@
1
+ """MCP server for open solar generation forecasts (Quartz / Open Climate Fix)."""
2
+
3
+ __version__ = "0.1.0"
@@ -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
@@ -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