From 8926cc7ae0679bca4e4b6d3f3ca9b4b616eeffe0 Mon Sep 17 00:00:00 2001 From: Andreas Date: Thu, 3 Sep 2026 17:53:22 +0200 Subject: [PATCH] feat(pvforecast): send plane horizon to Forecast.Solar A plane's userhorizon uses the same convention as the Forecast.Solar horizon query parameter (evenly distributed heights in degrees, starting north, clockwise), so pass it through instead of dropping it. Without it a shaded plane is forecast as if it had a free horizon. Co-Authored-By: Claude Opus 5 --- .../prediction/pvforecastforecastsolar.py | 11 +++++++++- tests/test_pvforecastforecastsolar.py | 22 +++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/src/akkudoktoreos/prediction/pvforecastforecastsolar.py b/src/akkudoktoreos/prediction/pvforecastforecastsolar.py index cfd2a330..73d2e5ad 100644 --- a/src/akkudoktoreos/prediction/pvforecastforecastsolar.py +++ b/src/akkudoktoreos/prediction/pvforecastforecastsolar.py @@ -17,6 +17,9 @@ Note on conventions: converts via ``az = surface_azimuth - 180``. - Response timestamps are local wall-clock; ``message.info.timezone`` is used to resolve them to absolute instants before EOS resamples them. + - A plane's ``userhorizon`` (PVGIS convention: evenly distributed horizon + heights in degrees, starting at north and going clockwise) is passed on as + the ``horizon`` query parameter, which uses the same convention. """ import re @@ -94,7 +97,13 @@ class PVForecastForecastSolar(PVForecastProvider): api_key = self._api_key if api_key: base = f"{base}/{api_key}" - return f"{base}/estimate/{latitude}/{longitude}/{float(tilt)}/{fs_az}/{float(peakpower)}" + url = f"{base}/estimate/{latitude}/{longitude}/{float(tilt)}/{fs_az}/{float(peakpower)}" + # Horizon shading: same convention on both sides (evenly distributed + # heights in degrees, starting north, clockwise), so pass it through. + horizon = getattr(plane, "userhorizon", None) + if horizon: + url = f"{url}?horizon={','.join(str(float(value)) for value in horizon)}" + return url @cache_in_file(with_ttl="1 hour") def _request_forecast(self) -> dict: diff --git a/tests/test_pvforecastforecastsolar.py b/tests/test_pvforecastforecastsolar.py index c0b6daa6..64c74ba3 100644 --- a/tests/test_pvforecastforecastsolar.py +++ b/tests/test_pvforecastforecastsolar.py @@ -127,3 +127,25 @@ def test_request_forecast_raises_on_http_error(pvforecast_instance): with pytest.raises(RuntimeError) as exc_info: pvforecast_instance._request_forecast(force_update=True) assert "Failed to fetch pvforecast from Forecast.Solar" in str(exc_info.value) + + +def test_plane_url_passes_userhorizon(config_eos): + """A plane's userhorizon is forwarded as the Forecast.Solar horizon parameter.""" + _config( + config_eos, + planes=[ + { + "surface_tilt": 87.9, + "surface_azimuth": 175.0, + "peakpower": 13.11, + "userhorizon": [28.0, 34.0, 32.0, 60.0], + } + ], + ) + pv = PVForecastForecastSolar( + config=config_eos.load, start_datetime=pendulum.datetime(2025, 1, 1, tz="UTC") + ) + with patch("requests.get", return_value=_http({})) as mock_get: + pv._request_forecast(force_update=True) # type: ignore + url = mock_get.call_args[0][0] + assert url.endswith("/estimate/52.5/13.4/87.9/-5.0/13.11?horizon=28.0,34.0,32.0,60.0")