fix: apply inverter_paco cap and system loss in PVForecastPVLib - #1504⁠

Planes sharing one inverter_paco feed a single physical inverter, but their AC output was summed without any cap, so a 15 kW inverter could report 21-25 kW. This PR groups the per-plane AC output by inverter_paco and clips each group's sum to its rating; planes without a rating are left alone. It also applies the configured system loss (PVGIS-style percent, same semantics as PVForecastAkkudoktorLocal) to DC and AC output instead of warning that it is unsupported.
This commit is contained in:
ChenYi-Nnergy
2026-11-09 14:41:16 +01:00
committed by GitHub
parent b92d115a39
commit 38b07f5a5b
3 changed files with 141 additions and 12 deletions
+7
View File
@@ -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,
+21 -12
View File
@@ -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)
+113
View File
@@ -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."""