diff --git a/docs/akkudoktoreos/prediction.md b/docs/akkudoktoreos/prediction.md index 58e576cb..a7bb6116 100644 --- a/docs/akkudoktoreos/prediction.md +++ b/docs/akkudoktoreos/prediction.md @@ -1014,6 +1014,13 @@ uses the configured PV system geometry together with the weather prediction simulate the expected DC module power and AC inverter output. The provider supports multiple PV planes and automatically sums their power. +Planes that share the same `inverter_paco` feed a single physical inverter: +their combined AC output is clipped to that rating, so the forecast never +exceeds the inverter's AC power rating. Planes with different ratings are +treated as separate inverters, and planes without a rating are not clipped. +The configured system loss (`planes[].loss`, percent) is applied to both DC +and AC output as a factor of `1 - loss / 100`, with the same semantics as +`PVForecastAkkudoktorLocal`. Module and inverter models are selected from the CEC database by name or by their nominal power rating. AkkudoktorEOS automatically generates and caches the required CEC databases on first use by combining the current SAM database, diff --git a/src/akkudoktoreos/prediction/pvforecastpvlib.py b/src/akkudoktoreos/prediction/pvforecastpvlib.py index f83d60d9..d8291960 100644 --- a/src/akkudoktoreos/prediction/pvforecastpvlib.py +++ b/src/akkudoktoreos/prediction/pvforecastpvlib.py @@ -313,21 +313,11 @@ class PVForecastPVLib(PredictionMixin, PVForecastProvider): "optimalangles", f"optimalangles is currently not supported by the {self.provider_id()} provider.", ) - if plane.loss not in (None, 0): - self._warn_once( - "loss", - f"loss is currently not supported by the {self.provider_id()} provider.", - ) if plane.trackingtype not in (None, 0): self._warn_once( "trackingtype", f"trackingtype is currently not supported by the {self.provider_id()} provider.", ) - if plane.inverter_paco is not None: - self._warn_once( - "inverter_paco", - f"inverter_paco is currently not supported by the {self.provider_id()} provider.", - ) # - mandatory parameters if plane.surface_tilt is None: raise ValueError(f"Plane {plane_idx}: surface_tilt must be configured.") @@ -402,10 +392,29 @@ class PVForecastPVLib(PredictionMixin, PVForecastProvider): }, index=df_weather.index, ) + # Planes sharing the same inverter_paco feed one physical inverter: + # their combined AC output must not exceed that rating. Planes with + # distinct ratings have their own inverter; planes without a rating + # are not clipped. `loss` is the PVGIS-style lump of soiling, + # mismatch, wiring and ageing (percent), with the same semantics as + # PVForecastAkkudoktorLocal. + ac_by_inverter: dict[float | None, pd.Series] = {} for plane_idx in range(len(self.config.pvforecast.planes)): + plane = self.config.pvforecast.planes[plane_idx] result = run_single_config(plane_idx) - df_pvforecast["pv_dc_power"] += result.dc["p_mp"].fillna(0.0) - df_pvforecast["ac_power"] += result.ac.fillna(0.0) + loss = plane.loss if plane.loss is not None else 0.0 + loss_factor = max(0.0, 1.0 - float(loss) / 100.0) + df_pvforecast["pv_dc_power"] += result.dc["p_mp"].fillna(0.0) * loss_factor + ac = result.ac.fillna(0.0) * loss_factor + paco = float(plane.inverter_paco) if plane.inverter_paco is not None else None + if paco in ac_by_inverter: + ac_by_inverter[paco] = ac_by_inverter[paco] + ac + else: + ac_by_inverter[paco] = ac + for paco, ac_sum in ac_by_inverter.items(): + if paco is not None: + ac_sum = ac_sum.clip(upper=paco) + df_pvforecast["ac_power"] += ac_sum # replace any negative PV values with zero df_pvforecast["pv_dc_power"] = df_pvforecast["pv_dc_power"].clip(lower=0.0) diff --git a/tests/test_pvforecastpvlib.py b/tests/test_pvforecastpvlib.py index 1f8f9535..2a4bdaa4 100644 --- a/tests/test_pvforecastpvlib.py +++ b/tests/test_pvforecastpvlib.py @@ -1518,6 +1518,119 @@ class TestCalculation: weather.index, ) + @patch("pvlib.modelchain.ModelChain") + def test_shared_inverter_cap_and_loss( + self, + mock_modelchain, + provider, + sample_settings_4planes, + ): + """Planes sharing one inverter shall not exceed its rating; loss shall apply. + + The 4-plane fixture configures inverter_paco 10000/10000/2000/1400 + with the default loss of 14 %. With 9000 W AC per plane, the two + planes sharing the 10000 W inverter must contribute 10000 W together + instead of 15480 W, and every plane's output must carry the loss. + """ + + index = pd.date_range( + "2024-06-01", + periods=2, + freq="15min", + tz="Europe/Berlin", + ) + + weather = pd.DataFrame( + { + "ghi": [900.0] * 2, + "dni": [800.0] * 2, + "dhi": [100.0] * 2, + "temp_air": [20.0] * 2, + "wind_speed": [2.0] * 2, + "relative_humidity": [60.0] * 2, + "precipitable_water": [1.5] * 2, + "cloud_cover": [0.0] * 2, + }, + index=index, + ) + + mc = MagicMock() + + mc.results.dc = pd.DataFrame( + { + "p_mp": [10000.0, 10000.0], + }, + index=index, + ) + + mc.results.ac = pd.Series( + [9000.0, 9000.0], + index=index, + ) + + mock_modelchain.return_value = mc + + result = provider._calculate_pvlib_power(weather) + + # Loss factor 0.86: DC 4 x 10000 x 0.86 = 34400. + assert result["pv_dc_power"].tolist() == pytest.approx([34400.0, 34400.0]) + # AC per plane 9000 x 0.86 = 7740; shared 10000 W cap clips + # 7740 + 7740 to 10000; single-plane caps clip 7740 to 2000 and 1400. + assert result["ac_power"].tolist() == pytest.approx([13400.0, 13400.0]) + + @patch("pvlib.modelchain.ModelChain") + def test_loss_applies_without_clipping( + self, + mock_modelchain, + provider, + sample_settings_1plane, + ): + """Configured loss shall reduce power even below the inverter rating.""" + + index = pd.date_range( + "2024-06-01", + periods=1, + freq="15min", + tz="Europe/Berlin", + ) + + weather = pd.DataFrame( + { + "ghi": [900.0], + "dni": [800.0], + "dhi": [100.0], + "temp_air": [20.0], + "wind_speed": [2.0], + "relative_humidity": [60.0], + "precipitable_water": [1.5], + "cloud_cover": [0.0], + }, + index=index, + ) + + mc = MagicMock() + + mc.results.dc = pd.DataFrame( + { + "p_mp": [10000.0], + }, + index=index, + ) + + mc.results.ac = pd.Series( + [9000.0], + index=index, + ) + + mock_modelchain.return_value = mc + + result = provider._calculate_pvlib_power(weather) + + # Single plane, default loss 14 %: 10000 x 0.86 = 8600 DC, + # 9000 x 0.86 = 7740 AC, below the 10000 W inverter rating. + assert result["pv_dc_power"].tolist() == pytest.approx([8600.0]) + assert result["ac_power"].tolist() == pytest.approx([7740.0]) + def test_get_model_power_none(self, provider): """Unknown model parameters return None."""