mirror of
https://github.com/Akkudoktor-EOS/EOS.git
synced 2026-11-13 09:13:30 +01:00
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:
@@ -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.
|
simulate the expected DC module power and AC inverter output.
|
||||||
|
|
||||||
The provider supports multiple PV planes and automatically sums their power.
|
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
|
Module and inverter models are selected from the CEC database by name or by
|
||||||
their nominal power rating. AkkudoktorEOS automatically generates and caches
|
their nominal power rating. AkkudoktorEOS automatically generates and caches
|
||||||
the required CEC databases on first use by combining the current SAM database,
|
the required CEC databases on first use by combining the current SAM database,
|
||||||
|
|||||||
@@ -313,21 +313,11 @@ class PVForecastPVLib(PredictionMixin, PVForecastProvider):
|
|||||||
"optimalangles",
|
"optimalangles",
|
||||||
f"optimalangles is currently not supported by the {self.provider_id()} provider.",
|
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):
|
if plane.trackingtype not in (None, 0):
|
||||||
self._warn_once(
|
self._warn_once(
|
||||||
"trackingtype",
|
"trackingtype",
|
||||||
f"trackingtype is currently not supported by the {self.provider_id()} provider.",
|
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
|
# - mandatory parameters
|
||||||
if plane.surface_tilt is None:
|
if plane.surface_tilt is None:
|
||||||
raise ValueError(f"Plane {plane_idx}: surface_tilt must be configured.")
|
raise ValueError(f"Plane {plane_idx}: surface_tilt must be configured.")
|
||||||
@@ -402,10 +392,29 @@ class PVForecastPVLib(PredictionMixin, PVForecastProvider):
|
|||||||
},
|
},
|
||||||
index=df_weather.index,
|
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)):
|
for plane_idx in range(len(self.config.pvforecast.planes)):
|
||||||
|
plane = self.config.pvforecast.planes[plane_idx]
|
||||||
result = run_single_config(plane_idx)
|
result = run_single_config(plane_idx)
|
||||||
df_pvforecast["pv_dc_power"] += result.dc["p_mp"].fillna(0.0)
|
loss = plane.loss if plane.loss is not None else 0.0
|
||||||
df_pvforecast["ac_power"] += result.ac.fillna(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
|
# replace any negative PV values with zero
|
||||||
df_pvforecast["pv_dc_power"] = df_pvforecast["pv_dc_power"].clip(lower=0.0)
|
df_pvforecast["pv_dc_power"] = df_pvforecast["pv_dc_power"].clip(lower=0.0)
|
||||||
|
|||||||
@@ -1518,6 +1518,119 @@ class TestCalculation:
|
|||||||
weather.index,
|
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):
|
def test_get_model_power_none(self, provider):
|
||||||
"""Unknown model parameters return None."""
|
"""Unknown model parameters return None."""
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user