Skip to content

Commit 7703438

Browse files
artspbJunieklaasnicolaasjunie-agent
authored
Add support for multiple solar planes (#275)
* Add support for multiple solar planes (#171) Add the ability to specify multiple planes for solar panel configurations. - Add new Plane dataclass with declination, azimuth, and kwp fields - Add planes parameter to ForecastSolar class - Build multi-plane URL paths for estimate and validate_plane methods - Silently ignore additional planes when no API key is provided - Update README with documentation and usage examples - Add comprehensive tests for multi-plane functionality Co-authored-by: Junie <noreply@jb.gg> * Fix linter errors in test_models.py - Remove unused snapshot parameter from test_planes_ignored_without_api_key - Split long comment to comply with line length limit * Add linting section to README.md - Add recommendation to run ruff linter before committing - Include commands for checking and auto-fixing linting issues * Revert "Add linting section to README.md" This reverts commit 685cc7d. * Add example file demonstrating multiple planes functionality Co-authored-by: Junie <noreply@jb.gg> * Update examples/planes.py Co-authored-by: Klaas Schoute <klaas_schoute@hotmail.com> * docs: clarify subscription requirements for multiple planes and actual parameter Co-authored-by: Junie <noreply@jb.gg> * fix: handle 404 errors in API requests Explicitly handle HTTP 404 status codes in _request method and raise ForecastSolarRequestError. Added tests to verify the behavior. Co-authored-by: Junie <junie@jetbrains.com> --------- Co-authored-by: Junie <noreply@jb.gg> Co-authored-by: Klaas Schoute <klaas_schoute@hotmail.com> Co-authored-by: Junie <junie@jetbrains.com>
1 parent 8405e4d commit 7703438

9 files changed

Lines changed: 315 additions & 16 deletions

File tree

README.md

Lines changed: 54 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -85,23 +85,68 @@ async def main() -> None:
8585
print(estimate)
8686

8787

88+
if __name__ == "__main__":
89+
asyncio.run(main())
90+
```
91+
92+
### Multiple Planes
93+
94+
If you have solar panels facing different directions, you can specify multiple planes.
95+
96+
**Note:** Using multiple planes requires both an API key and a Personal Plus (or higher) subscription. If no API key is provided, additional planes will be silently ignored. See the [subscription plan overview][forecast-subscription] for more information.
97+
98+
```python
99+
import asyncio
100+
101+
from forecast_solar import ForecastSolar, Plane
102+
103+
104+
async def main() -> None:
105+
"""Show example with multiple planes."""
106+
async with ForecastSolar(
107+
api_key="YOUR_API_KEY",
108+
latitude=52.16,
109+
longitude=4.47,
110+
# First plane (primary)
111+
declination=20,
112+
azimuth=10,
113+
kwp=2.160,
114+
# Additional planes
115+
planes=[
116+
Plane(declination=30, azimuth=-90, kwp=1.5), # Second plane
117+
Plane(declination=25, azimuth=90, kwp=1.0), # Third plane
118+
],
119+
) as forecast:
120+
estimate = await forecast.estimate()
121+
print(estimate)
122+
123+
88124
if __name__ == "__main__":
89125
asyncio.run(main())
90126
```
91127

92128
## ForecastSolar object
93129

130+
| Parameter | value type | Description |
131+
| --------- | ---------- |-------------------------------------------------------------------------------------------------------------|
132+
| `api_key` | `str` | Your API key from [forecast.solar](https://forecast.solar) (optional) |
133+
| `declination` | `int` | The tilt of the solar panels (required) |
134+
| `azimuth` | `int` | The direction the solar panels are facing (required) |
135+
| `kwp` | `float` | The size of the solar panels in kWp (required) |
136+
| `damping` | `float` | The damping of the solar panels, [read this][forecast-damping] for more information (optional) |
137+
| `damping_morning` | `float` | The damping of the solar panels in the morning (optional) |
138+
| `damping_evening` | `float` | The damping of the solar panels in the evening (optional) |
139+
| `inverter` | `float` | The maximum power of your inverter in kilo watts (optional) |
140+
| `horizon` | `str` | A list of **comma separated** degrees values, [read this][forecast-horizon] for more information (optional) |
141+
| `planes` | `list[Plane]` | A list of additional Plane objects for multi-plane setups. Only used when an API key is provided (optional) |
142+
143+
## Plane object
144+
94145
| Parameter | value type | Description |
95146
| --------- | ---------- | ----------- |
96-
| `api_key` | `str` | Your API key from [forecast.solar](https://forecast.solar) (optional) |
97-
| `declination` | `int` | The tilt of the solar panels (required) |
98-
| `azimuth` | `int` | The direction the solar panels are facing (required) |
147+
| `declination` | `float` | The tilt of the solar panels (required) |
148+
| `azimuth` | `float` | The direction the solar panels are facing (required) |
99149
| `kwp` | `float` | The size of the solar panels in kWp (required) |
100-
| `damping` | `float` | The damping of the solar panels, [read this][forecast-damping] for more information (optional) |
101-
| `damping_morning` | `float` | The damping of the solar panels in the morning (optional) |
102-
| `damping_evening` | `float` | The damping of the solar panels in the evening (optional) |
103-
| `inverter` | `float` | The maximum power of your inverter in kilo watts (optional) |
104-
| `horizon` | `str` | A list of **comma separated** degrees values, [read this][forecast-horizon] for more information (optional) |
105150

106151
## estimate() method
107152

@@ -192,6 +237,7 @@ SOFTWARE.
192237
<!-- LINKS -->
193238
[forecast-horizon]: https://doc.forecast.solar/doku.php?id=api#horizon
194239
[forecast-damping]: https://doc.forecast.solar/doku.php?id=damping
240+
[forecast-subscription]: https://doc.forecast.solar/account_models
195241

196242
<!-- MARKDOWN LINKS & IMAGES -->
197243
[maintenance-shield]: https://img.shields.io/maintenance/yes/2025.svg?style=for-the-badge

examples/planes.py

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
"""Example of how to use multiple planes with the Forecast.Solar API."""
2+
3+
import asyncio
4+
from datetime import UTC, datetime, timedelta
5+
from pprint import pprint # noqa: F401
6+
7+
from forecast_solar import ForecastSolar, ForecastSolarRatelimitError, Plane
8+
9+
10+
async def main() -> None:
11+
"""Get an estimate from the Forecast.Solar API using multiple planes.
12+
13+
This example demonstrates how to configure multiple solar panel arrays
14+
(planes) with different orientations. Using multiple planes requires both
15+
an API key and a Personal Plus or higher subscription.
16+
"""
17+
async with ForecastSolar(
18+
api_key="your-api-key", # API key is required for multiple planes
19+
latitude=52.16,
20+
longitude=4.47,
21+
# First plane configuration (main roof)
22+
declination=20,
23+
azimuth=10,
24+
kwp=2.160,
25+
# Additional planes (e.g., garage roof, secondary array)
26+
planes=[
27+
Plane(declination=30, azimuth=-90, kwp=1.5), # West-facing plane
28+
Plane(declination=30, azimuth=90, kwp=1.5), # East-facing plane
29+
],
30+
) as forecast:
31+
try:
32+
estimate = await forecast.estimate()
33+
except ForecastSolarRatelimitError as err:
34+
print("Ratelimit reached")
35+
print(f"Rate limit resets at {err.reset_at}")
36+
reset_period = err.reset_at - datetime.now(UTC)
37+
# Strip microseconds as they are not informative
38+
reset_period -= timedelta(microseconds=reset_period.microseconds)
39+
print(f"That's in {reset_period}")
40+
return
41+
42+
# Uncomment this if you want to see what's in the estimate arrays
43+
# pprint(dataclasses.asdict(estimate))
44+
print()
45+
print(f"energy_production_today: {estimate.energy_production_today}")
46+
print(
47+
"energy_production_today_remaining: "
48+
f"{estimate.energy_production_today_remaining}"
49+
)
50+
print(
51+
f"power_highest_peak_time_today: {estimate.power_highest_peak_time_today}"
52+
)
53+
print(f"energy_production_tomorrow: {estimate.energy_production_tomorrow}")
54+
print(
55+
"power_highest_peak_time_tomorrow: "
56+
f"{estimate.power_highest_peak_time_tomorrow}"
57+
)
58+
print()
59+
print(f"power_production_now: {estimate.power_production_now}")
60+
print(
61+
"power_production in 1 hour: "
62+
f"{estimate.power_production_at_time(estimate.now() + timedelta(hours=1))}"
63+
)
64+
print(
65+
"power_production in 6 hours: "
66+
f"{estimate.power_production_at_time(estimate.now() + timedelta(hours=6))}"
67+
)
68+
print(
69+
"power_production in 12 hours: "
70+
f"{estimate.power_production_at_time(estimate.now() + timedelta(hours=12))}"
71+
)
72+
print(
73+
"power_production in 24 hours: "
74+
f"{estimate.power_production_at_time(estimate.now() + timedelta(hours=24))}"
75+
)
76+
print()
77+
print(f"energy_current_hour: {estimate.energy_current_hour}")
78+
print(f"energy_production next hour: {estimate.sum_energy_production(1)}")
79+
print(f"energy_production next 6 hours: {estimate.sum_energy_production(6)}")
80+
print(f"energy_production next 12 hours: {estimate.sum_energy_production(12)}")
81+
print(f"energy_production next 24 hours: {estimate.sum_energy_production(24)}")
82+
print(f"timezone: {estimate.timezone}")
83+
print(f"account_type: {estimate.account_type}")
84+
print(forecast.ratelimit)
85+
86+
87+
if __name__ == "__main__":
88+
asyncio.run(main())

src/forecast_solar/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
ForecastSolarRequestError,
1010
)
1111
from .forecast_solar import ForecastSolar
12-
from .models import AccountType, Estimate, Ratelimit
12+
from .models import AccountType, Estimate, Plane, Ratelimit
1313

1414
__all__ = [
1515
"AccountType",
@@ -21,5 +21,6 @@
2121
"ForecastSolarError",
2222
"ForecastSolarRatelimitError",
2323
"ForecastSolarRequestError",
24+
"Plane",
2425
"Ratelimit",
2526
]

src/forecast_solar/forecast_solar.py

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
ForecastSolarRatelimitError,
1717
ForecastSolarRequestError,
1818
)
19-
from .models import Estimate, Ratelimit
19+
from .models import Estimate, Plane, Ratelimit
2020

2121

2222
@dataclass
@@ -34,13 +34,30 @@ class ForecastSolar:
3434
damping_morning: float | None = None
3535
damping_evening: float | None = None
3636
horizon: str | None = None
37+
planes: list[Plane] | None = None
3738

3839
session: ClientSession | None = None
3940
ratelimit: Ratelimit | None = None
4041
inverter: float | None = None
4142
_close_session: bool = False
4243
_base_url = URL("https://api.forecast.solar")
4344

45+
def _build_plane_path(self) -> str:
46+
"""Build the plane path segment for API URLs.
47+
48+
Returns
49+
-------
50+
A string containing the plane parameters for all planes.
51+
Additional planes are only included if an API key is provided.
52+
53+
"""
54+
path = f"{self.declination}/{self.azimuth}/{self.kwp}"
55+
# Only include additional planes if an API key is provided
56+
if self.planes and self.api_key is not None:
57+
for plane in self.planes:
58+
path += f"/{plane.declination}/{plane.azimuth}/{plane.kwp}"
59+
return path
60+
4461
async def _request(
4562
self,
4663
uri: str,
@@ -118,6 +135,10 @@ async def _request(
118135
data = await response.json()
119136
raise ForecastSolarRatelimitError(data["message"])
120137

138+
if response.status == 404:
139+
data = await response.json()
140+
raise ForecastSolarRequestError(data["message"])
141+
121142
if rate_limit and response.status == 200:
122143
self.ratelimit = Ratelimit.from_response(response)
123144

@@ -142,8 +163,7 @@ async def validate_plane(self) -> bool:
142163
143164
"""
144165
await self._request(
145-
f"check/{self.latitude}/{self.longitude}"
146-
f"/{self.declination}/{self.azimuth}/{self.kwp}",
166+
f"check/{self.latitude}/{self.longitude}/{self._build_plane_path()}",
147167
rate_limit=False,
148168
authenticate=False,
149169
)
@@ -187,8 +207,7 @@ async def estimate(self, actual: float = 0) -> Estimate:
187207
params["actual"] = str(actual)
188208

189209
data = await self._request(
190-
f"estimate/{self.latitude}/{self.longitude}"
191-
f"/{self.declination}/{self.azimuth}/{self.kwp}",
210+
f"estimate/{self.latitude}/{self.longitude}/{self._build_plane_path()}",
192211
params=params,
193212
)
194213

src/forecast_solar/models.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,23 @@ class AccountType(StrEnum):
5050
PROFESSIONAL = "professional"
5151

5252

53+
@dataclass
54+
class Plane:
55+
"""Represents a solar plane configuration.
56+
57+
Attributes
58+
----------
59+
declination: The tilt of the solar panels (0-90 degrees).
60+
azimuth: The direction the solar panels are facing (-180 to 180 degrees).
61+
kwp: The size of the solar panels in kWp.
62+
63+
"""
64+
65+
declination: float
66+
azimuth: float
67+
kwp: float
68+
69+
5370
@dataclass
5471
class Estimate:
5572
"""Object holding estimate forecast results from Forecast.Solar.

tests/__snapshots__/test_models.ambr

Lines changed: 3 additions & 0 deletions
Large diffs are not rendered by default.

tests/conftest.py

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
import pytest
66
from aiohttp import ClientSession
77

8-
from forecast_solar import ForecastSolar
8+
from forecast_solar import ForecastSolar, Plane
99

1010

1111
@pytest.fixture(name="forecast_client")
@@ -46,3 +46,24 @@ async def client_api_key() -> AsyncGenerator[ForecastSolar, None]:
4646
) as forecast_key_client,
4747
):
4848
yield forecast_key_client
49+
50+
51+
@pytest.fixture(name="forecast_multi_plane_client")
52+
async def client_multi_plane() -> AsyncGenerator[ForecastSolar, None]:
53+
"""Return a Forecast.Solar client with multiple planes."""
54+
async with (
55+
ClientSession() as session,
56+
ForecastSolar(
57+
api_key="myapikey",
58+
latitude=52.16,
59+
longitude=4.47,
60+
declination=20,
61+
azimuth=10,
62+
kwp=2.160,
63+
planes=[
64+
Plane(declination=30, azimuth=-90, kwp=1.5),
65+
],
66+
session=session,
67+
) as forecast_multi_plane_client,
68+
):
69+
yield forecast_multi_plane_client

tests/test_exceptions.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,3 +128,24 @@ async def test_status_502(
128128
)
129129
with pytest.raises(ForecastSolarConnectionError):
130130
assert await forecast_client._request("test")
131+
132+
133+
async def test_status_404(
134+
aresponses: ResponsesMockServer,
135+
forecast_client: ForecastSolar,
136+
) -> None:
137+
"""Test response status 404."""
138+
aresponses.add(
139+
"api.forecast.solar",
140+
"/test",
141+
"GET",
142+
aresponses.Response(
143+
status=404,
144+
headers={
145+
"Content-Type": "application/json",
146+
},
147+
text='{"message": {"code": 404, "text": "Not Found"}}',
148+
),
149+
)
150+
with pytest.raises(ForecastSolarRequestError):
151+
assert await forecast_client._request("test")

0 commit comments

Comments
 (0)