mirror of
https://github.com/Akkudoktor-EOS/EOS.git
synced 2026-10-08 23:46:38 +00:00
fix(optimization): let the AC setpoint cap the total charge where the inverter does (#1334)
* fix(optimization): let the AC setpoint cap the total charge where the inverter does Some hybrid inverters limit the battery's whole charge current to the grid charge setpoint while grid charging is on. A Deye 12K in time-of-use grid charging with max_grid_charge_current = 75 A charges at ~80 A even with 8 kW of PV, and exports the rest - including the surplus of micro inverters on the grid side. GENETIC modelled an AC slot as "PV surplus first, grid adds ac_charge x the remaining charge power", so under PV surplus an AC slot looked at least as good as a DC slot and the optimizer picked a low AC factor, while the real system exported what the plan meant to store. New inverter config option devices.inverters[].ac_charge_limits_total_charge (GENETIC scope, default False, so the existing model is unchanged). When True, an AC slot caps the battery's raw charge at ac_charge x max_charge_power_w from all sources: PV surplus above the cap is exported, the grid only fills what PV leaves of it. The option reaches the optimizer through InverterCommonSettings.to_genetic_param, i.e. POST /v1/optimize and the EMS run. GENETIC0 (POST /optimize) is left as it is. The AC slot logic moves into Inverter (ac_charge_factor, begin_ac_charge_slot, charge_battery_from_grid) so the simulation and the tail value curve share it; Battery gets a per-slot charge cap that reset() lifts again. * test(optimization): narrow optional simulation arrays for mypy The annotated fixture indexes GeneticSimulation arrays typed as Optional; assert them first so the locked mypy hook passes.
This commit is contained in:
@@ -11249,6 +11249,19 @@
|
||||
"GENETIC0"
|
||||
]
|
||||
},
|
||||
"ac_charge_limits_total_charge": {
|
||||
"type": "boolean",
|
||||
"title": "Ac Charge Limits Total Charge",
|
||||
"description": "True if the AC charge setpoint caps the battery's total charge power, PV included. Some hybrid inverters (e.g. Deye in time-of-use grid charging) limit the whole charge current to the grid charge current; PV surplus above it is exported, not stored. False keeps the default model: PV surplus charges first and the grid adds ac_charge x max_charge_power_w on top.",
|
||||
"default": false,
|
||||
"examples": [
|
||||
false,
|
||||
true
|
||||
],
|
||||
"x-scope": [
|
||||
"GENETIC"
|
||||
]
|
||||
},
|
||||
"battery_id": {
|
||||
"anyOf": [
|
||||
{
|
||||
@@ -11647,6 +11660,19 @@
|
||||
"GENETIC0"
|
||||
]
|
||||
},
|
||||
"ac_charge_limits_total_charge": {
|
||||
"type": "boolean",
|
||||
"title": "Ac Charge Limits Total Charge",
|
||||
"description": "True if the AC charge setpoint caps the battery's total charge power, PV included. Some hybrid inverters (e.g. Deye in time-of-use grid charging) limit the whole charge current to the grid charge current; PV surplus above it is exported, not stored. False keeps the default model: PV surplus charges first and the grid adds ac_charge x max_charge_power_w on top.",
|
||||
"default": false,
|
||||
"examples": [
|
||||
false,
|
||||
true
|
||||
],
|
||||
"x-scope": [
|
||||
"GENETIC"
|
||||
]
|
||||
},
|
||||
"battery_id": {
|
||||
"anyOf": [
|
||||
{
|
||||
@@ -12061,6 +12087,16 @@
|
||||
0,
|
||||
5000
|
||||
]
|
||||
},
|
||||
"ac_charge_limits_total_charge": {
|
||||
"type": "boolean",
|
||||
"title": "Ac Charge Limits Total Charge",
|
||||
"description": "True if the AC charge setpoint caps the battery's total charge power, PV included. PV surplus above it is exported, not stored. False keeps the default model: PV surplus charges first and the grid adds ac_charge x max_charge_power_w on top.",
|
||||
"default": false,
|
||||
"examples": [
|
||||
false,
|
||||
true
|
||||
]
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
|
||||
@@ -213,6 +213,9 @@ class Battery:
|
||||
self.charge_array = np.full(self.prediction_hours, 0)
|
||||
self._discharged_raw_wh_per_slot = np.zeros(self.prediction_hours, dtype=float)
|
||||
self._charged_raw_wh_per_slot = np.zeros(self.prediction_hours, dtype=float)
|
||||
# Optional per-slot cap on the raw charge energy from all sources. It is
|
||||
# unbounded unless an inverter restricts a slot (see limit_slot_charge).
|
||||
self._charge_limit_raw_wh_per_slot = np.full(self.prediction_hours, np.inf)
|
||||
self.soc_wh = (self.initial_soc_percentage / 100) * self.capacity_wh
|
||||
self.min_soc_wh = (self.min_soc_percentage / 100) * self.capacity_wh
|
||||
self.max_soc_wh = (self.max_soc_percentage / 100) * self.capacity_wh
|
||||
@@ -258,6 +261,21 @@ class Battery:
|
||||
self.charge_array = np.full(self.prediction_hours, 0)
|
||||
self._discharged_raw_wh_per_slot = np.zeros(self.prediction_hours, dtype=float)
|
||||
self._charged_raw_wh_per_slot = np.zeros(self.prediction_hours, dtype=float)
|
||||
self._charge_limit_raw_wh_per_slot = np.full(self.prediction_hours, np.inf)
|
||||
|
||||
def limit_slot_charge(self, hour: int, raw_wh: float) -> None:
|
||||
"""Cap the raw energy the battery may take in one slot, from all sources.
|
||||
|
||||
The cap covers PV and grid charging together and is not raised by later
|
||||
calls within the same slot. It is lifted again by ``reset()``.
|
||||
|
||||
Args:
|
||||
hour (int): Slot index.
|
||||
raw_wh (float): Maximum raw charge energy [Wh] before charging efficiency.
|
||||
"""
|
||||
self._charge_limit_raw_wh_per_slot[hour] = min(
|
||||
self._charge_limit_raw_wh_per_slot[hour], max(float(raw_wh), 0.0)
|
||||
)
|
||||
|
||||
def rated_discharge_energy_wh(self) -> float:
|
||||
"""Return the DC energy one full-power discharge slot delivers.
|
||||
@@ -388,6 +406,7 @@ class Battery:
|
||||
|
||||
- Available SoC headroom (``max_soc_wh − soc_wh``)
|
||||
- ``max_charge_power_w``
|
||||
- A slot limit set by ``limit_slot_charge()``, if any
|
||||
- ``charging_efficiency``
|
||||
|
||||
Args:
|
||||
@@ -428,7 +447,11 @@ class Battery:
|
||||
# Scale the power cap [W] to a per-slot energy cap [Wh] (W x slot hours).
|
||||
# At slot_duration_h=1.0 (hourly) this equals the legacy power value.
|
||||
max_charge_per_slot_wh_fast = max(
|
||||
self.max_charge_power_w * self.slot_duration_h - self._charged_raw_wh_per_slot[hour],
|
||||
min(
|
||||
self.max_charge_power_w * self.slot_duration_h,
|
||||
self._charge_limit_raw_wh_per_slot[hour],
|
||||
)
|
||||
- self._charged_raw_wh_per_slot[hour],
|
||||
0.0,
|
||||
)
|
||||
charging_efficiency_fast = self.charging_efficiency
|
||||
|
||||
@@ -56,6 +56,18 @@ class InverterParameters(DeviceParameters):
|
||||
"examples": [None, 0, 5000],
|
||||
},
|
||||
)
|
||||
ac_charge_limits_total_charge: bool = Field(
|
||||
default=False,
|
||||
json_schema_extra={
|
||||
"description": (
|
||||
"True if the AC charge setpoint caps the battery's total charge power, "
|
||||
"PV included. PV surplus above it is exported, not stored. False keeps "
|
||||
"the default model: PV surplus charges first and the grid adds "
|
||||
"ac_charge x max_charge_power_w on top."
|
||||
),
|
||||
"examples": [False, True],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class Inverter:
|
||||
@@ -84,6 +96,65 @@ class Inverter:
|
||||
# This value remains a power [W]. GeneticSimulation converts it into a
|
||||
# slot-independent charge-factor limit.
|
||||
self.max_ac_charge_power_w = self.parameters.max_ac_charge_power_w
|
||||
self.ac_charge_limits_total_charge = self.parameters.ac_charge_limits_total_charge
|
||||
|
||||
def ac_charge_factor(self, factor: float) -> float:
|
||||
"""Return the AC charge factor the inverter can actually execute.
|
||||
|
||||
The factor is a fraction of the battery's ``max_charge_power_w``. It is
|
||||
capped so that the AC input stays within ``max_ac_charge_power_w`` and
|
||||
is 0.0 when AC charging is impossible.
|
||||
"""
|
||||
if factor <= 0.0 or not self.battery or self.ac_to_dc_efficiency <= 0.0:
|
||||
return 0.0
|
||||
if self.max_ac_charge_power_w is not None and self.battery.max_charge_power_w > 0:
|
||||
# DC power = max_charge_power_w * factor
|
||||
# AC power = DC power / ac_to_dc_eff <= max_ac_charge_power_w
|
||||
factor = min(
|
||||
factor,
|
||||
self.max_ac_charge_power_w
|
||||
* self.ac_to_dc_efficiency
|
||||
/ self.battery.max_charge_power_w,
|
||||
)
|
||||
return max(factor, 0.0)
|
||||
|
||||
def begin_ac_charge_slot(self, hour: int, factor: float) -> None:
|
||||
"""Apply the AC charge setpoint of a slot before its PV is processed.
|
||||
|
||||
On inverters whose grid charge setpoint caps the total charge power, the
|
||||
battery takes at most ``factor`` of its rated charge power in this slot,
|
||||
PV included. Call ``process_energy`` afterwards so PV surplus above the
|
||||
cap is exported, as the inverter does.
|
||||
"""
|
||||
if self.ac_charge_limits_total_charge and self.battery and factor > 0.0:
|
||||
self.battery.limit_slot_charge(
|
||||
hour, self.battery.max_charge_power_w * self.slot_duration_h * factor
|
||||
)
|
||||
|
||||
def charge_battery_from_grid(self, hour: int, factor: float) -> tuple[float, float]:
|
||||
"""Charge the battery from the grid after PV was processed in this slot.
|
||||
|
||||
Default model: the grid adds ``factor`` of the rated charge power on top
|
||||
of the PV charge. With ``ac_charge_limits_total_charge`` the grid only
|
||||
fills what PV left of the slot cap set by ``begin_ac_charge_slot``.
|
||||
|
||||
Returns:
|
||||
tuple[float, float]: AC energy drawn from the grid [Wh] and the
|
||||
battery plus AC-to-DC conversion losses [Wh].
|
||||
"""
|
||||
if not self.battery or factor <= 0.0:
|
||||
return 0.0, 0.0
|
||||
if self.ac_charge_limits_total_charge:
|
||||
stored, battery_losses = self.battery.charge_energy(
|
||||
self.battery.max_charge_power_w * self.slot_duration_h * factor, hour
|
||||
)
|
||||
else:
|
||||
stored, battery_losses = self.battery.charge_energy(None, hour, charge_factor=factor)
|
||||
# DC energy entering the battery (before battery internal efficiency)
|
||||
dc_energy = stored + battery_losses
|
||||
# AC energy consumed from grid (accounts for AC->DC conversion loss)
|
||||
ac_energy = dc_energy / self.ac_to_dc_efficiency
|
||||
return ac_energy, battery_losses + (ac_energy - dc_energy)
|
||||
|
||||
def _discharge_battery_to_ac(self, requested_ac_wh: float, hour: int) -> tuple[float, float]:
|
||||
"""Discharge battery energy and convert it to AC energy."""
|
||||
|
||||
@@ -74,6 +74,21 @@ class InverterCommonSettings(DevicesBaseSettings):
|
||||
"x-scope": [str(ConfigScope.GENETIC), str(ConfigScope.GENETIC0)],
|
||||
},
|
||||
)
|
||||
ac_charge_limits_total_charge: bool = Field(
|
||||
default=False,
|
||||
json_schema_extra={
|
||||
"description": (
|
||||
"True if the AC charge setpoint caps the battery's total charge power, "
|
||||
"PV included. Some hybrid inverters (e.g. Deye in time-of-use grid "
|
||||
"charging) limit the whole charge current to the grid charge current; "
|
||||
"PV surplus above it is exported, not stored. False keeps the default "
|
||||
"model: PV surplus charges first and the grid adds "
|
||||
"ac_charge x max_charge_power_w on top."
|
||||
),
|
||||
"examples": [False, True],
|
||||
"x-scope": [str(ConfigScope.GENETIC)],
|
||||
},
|
||||
)
|
||||
battery_id: Optional[str] = Field(
|
||||
default=None,
|
||||
json_schema_extra={
|
||||
@@ -361,6 +376,7 @@ class InverterCommonSettings(DevicesBaseSettings):
|
||||
ac_to_dc_efficiency=self.ac_to_dc_efficiency,
|
||||
dc_to_ac_efficiency=self.dc_to_ac_efficiency,
|
||||
max_ac_charge_power_w=self.max_ac_charge_power_w,
|
||||
ac_charge_limits_total_charge=self.ac_charge_limits_total_charge,
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@@ -494,7 +494,18 @@ class GeneticSimulation(PydanticBaseModel):
|
||||
0.0
|
||||
)
|
||||
|
||||
# AC charge factor of this slot, capped by max_ac_charge_power_w
|
||||
ac_charge_factor = 0.0
|
||||
if battery_fast and ac_charging_possible:
|
||||
ac_charge_factor = ac_charge_hours_fast[hour]
|
||||
if inverter_fast:
|
||||
ac_charge_factor = inverter_fast.ac_charge_factor(ac_charge_factor)
|
||||
|
||||
if inverter_fast:
|
||||
# Some inverters cap the total charge power with the AC setpoint;
|
||||
# that cap has to be in place before PV charges the battery.
|
||||
if ac_charge_factor > 0.0:
|
||||
inverter_fast.begin_ac_charge_slot(hour, ac_charge_factor)
|
||||
energy_produced = pv_prediction_wh_fast[hour]
|
||||
hourly_feed_in_tariff = elect_revenue_per_hour_arr_fast[hour]
|
||||
# bat_grid_export_hours carries the export level per slot:
|
||||
@@ -522,39 +533,21 @@ class GeneticSimulation(PydanticBaseModel):
|
||||
hourly_feed_in_tariff = elect_revenue_per_hour_arr_fast[hour]
|
||||
|
||||
# AC PV Battery Charge
|
||||
if battery_fast:
|
||||
hour_ac_charge = ac_charge_hours_fast[hour]
|
||||
if hour_ac_charge > 0.0 and ac_charging_possible:
|
||||
# Cap charge factor by max_ac_charge_power_w if set
|
||||
effective_charge_factor = hour_ac_charge
|
||||
if max_ac_charge_w_fast is not None and battery_fast.max_charge_power_w > 0:
|
||||
# DC power = max_charge_power_w * factor
|
||||
# AC power = DC power / ac_to_dc_eff
|
||||
# AC power must be <= max_ac_charge_power_w
|
||||
max_dc_factor = (
|
||||
max_ac_charge_w_fast * ac_to_dc_eff_fast
|
||||
) / battery_fast.max_charge_power_w
|
||||
effective_charge_factor = min(effective_charge_factor, max_dc_factor)
|
||||
|
||||
if effective_charge_factor > 0:
|
||||
battery_charged_energy_actual, battery_losses_actual = (
|
||||
battery_fast.charge_energy(
|
||||
None, hour, charge_factor=effective_charge_factor
|
||||
)
|
||||
)
|
||||
|
||||
# DC energy entering the battery (before battery internal efficiency)
|
||||
dc_energy = battery_charged_energy_actual + battery_losses_actual
|
||||
# AC energy consumed from grid (accounts for AC→DC conversion loss)
|
||||
ac_energy = dc_energy / ac_to_dc_eff_fast
|
||||
# Inverter AC→DC conversion losses
|
||||
inverter_charge_losses = ac_energy - dc_energy
|
||||
|
||||
consumption += ac_energy
|
||||
energy_consumption_grid_actual += ac_energy
|
||||
losses_wh_per_hour[hour_idx] += (
|
||||
battery_losses_actual + inverter_charge_losses
|
||||
)
|
||||
if ac_charge_factor > 0.0 and battery_fast:
|
||||
if inverter_fast:
|
||||
ac_energy, ac_charge_losses = inverter_fast.charge_battery_from_grid(
|
||||
hour, ac_charge_factor
|
||||
)
|
||||
else:
|
||||
# Without an inverter the grid charges the battery losslessly
|
||||
# (AC-to-DC efficiency 1.0).
|
||||
stored, ac_charge_losses = battery_fast.charge_energy(
|
||||
None, hour, charge_factor=ac_charge_factor
|
||||
)
|
||||
ac_energy = stored + ac_charge_losses
|
||||
consumption += ac_energy
|
||||
energy_consumption_grid_actual += ac_energy
|
||||
losses_wh_per_hour[hour_idx] += ac_charge_losses
|
||||
|
||||
# Update hourly arrays
|
||||
if (
|
||||
|
||||
@@ -50,11 +50,15 @@ def _simulate_action(
|
||||
bat.soc_wh = float(energy_wh)
|
||||
bat._charged_raw_wh_per_slot.fill(0)
|
||||
bat._discharged_raw_wh_per_slot.fill(0)
|
||||
bat._charge_limit_raw_wh_per_slot.fill(np.inf)
|
||||
ac_enabled = inv.ac_to_dc_efficiency > 0 and (
|
||||
inv.max_ac_charge_power_w is None or inv.max_ac_charge_power_w > 0
|
||||
)
|
||||
bat.charge_array[0] = ac_rate if ac_rate > 0 and ac_enabled else dc
|
||||
bat.discharge_array[0] = discharge if export == 0 or tariff > 0 else 0
|
||||
rate = inv.ac_charge_factor(ac_rate)
|
||||
if rate > 0:
|
||||
inv.begin_ac_charge_slot(0, rate)
|
||||
sold, bought, losses, _ = inv.process_energy(
|
||||
pv,
|
||||
load,
|
||||
@@ -64,18 +68,11 @@ def _simulate_action(
|
||||
)
|
||||
ac_grid_charge_wh = 0.0
|
||||
if ac_rate > 0 and inv.ac_to_dc_efficiency > 0:
|
||||
rate = ac_rate
|
||||
if inv.max_ac_charge_power_w is not None and bat.max_charge_power_w > 0:
|
||||
rate = min(
|
||||
rate,
|
||||
inv.max_ac_charge_power_w * inv.ac_to_dc_efficiency / bat.max_charge_power_w,
|
||||
)
|
||||
bat.charge_array[0] = rate
|
||||
if rate > 0:
|
||||
stored, loss = bat.charge_energy(None, 0, charge_factor=rate)
|
||||
ac_grid_charge_wh = (stored + loss) / inv.ac_to_dc_efficiency
|
||||
ac_grid_charge_wh, ac_losses = inv.charge_battery_from_grid(0, rate)
|
||||
bought += ac_grid_charge_wh
|
||||
losses += loss + max(ac_grid_charge_wh - stored - loss, 0.0)
|
||||
losses += ac_losses
|
||||
if direct_marketing and tariff < 0:
|
||||
sold = 0.0
|
||||
discharged_wh = bat.discharged_energy_wh(0)
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
"""Tests for inverters whose AC charge setpoint caps the total battery charge.
|
||||
|
||||
With ``ac_charge_limits_total_charge`` an AC slot charges the battery with at
|
||||
most ``ac_charge x max_charge_power_w``, PV included (e.g. Deye time-of-use grid
|
||||
charging). PV surplus above that cap is exported; the grid only fills what PV
|
||||
leaves of the cap. Without the flag the default model applies: PV charges first
|
||||
and the grid adds ``ac_charge`` of the remaining charge power on top.
|
||||
"""
|
||||
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from akkudoktoreos.devices.genetic.battery import Battery, SolarPanelBatteryParameters
|
||||
from akkudoktoreos.devices.genetic.inverter import Inverter, InverterParameters
|
||||
from akkudoktoreos.devices.settings.invertersettings import InverterCommonSettings
|
||||
|
||||
MAX_CHARGE_W = 5000
|
||||
|
||||
|
||||
def _build(limits_total: bool, max_ac_charge_power_w=None) -> tuple[Inverter, Battery]:
|
||||
battery = Battery(
|
||||
SolarPanelBatteryParameters(
|
||||
device_id="battery1",
|
||||
capacity_wh=20000,
|
||||
initial_soc_percentage=20,
|
||||
charging_efficiency=0.9,
|
||||
discharging_efficiency=0.9,
|
||||
min_soc_percentage=0,
|
||||
max_soc_percentage=100,
|
||||
max_charge_power_w=MAX_CHARGE_W,
|
||||
),
|
||||
prediction_hours=4,
|
||||
)
|
||||
battery.reset()
|
||||
# reset() creates an integer array; charge factors are fractions.
|
||||
battery.charge_array = np.zeros(4)
|
||||
predictor = Mock()
|
||||
predictor.calculate_expected_direct_consumption.side_effect = min
|
||||
with patch(
|
||||
"akkudoktoreos.devices.genetic.inverter.get_eos_load_interpolator",
|
||||
return_value=predictor,
|
||||
):
|
||||
inverter = Inverter(
|
||||
InverterParameters(
|
||||
device_id="inverter1",
|
||||
max_power_wh=10000,
|
||||
battery_id="battery1",
|
||||
ac_to_dc_efficiency=1.0,
|
||||
max_ac_charge_power_w=max_ac_charge_power_w,
|
||||
ac_charge_limits_total_charge=limits_total,
|
||||
),
|
||||
battery=battery,
|
||||
)
|
||||
return inverter, battery
|
||||
|
||||
|
||||
def _run_ac_slot(inverter: Inverter, battery: Battery, pv_wh: float, factor: float):
|
||||
"""Run one AC slot the way GeneticSimulation.simulate() does."""
|
||||
battery.charge_array[0] = factor
|
||||
battery.discharge_array[0] = 0
|
||||
rate = inverter.ac_charge_factor(factor)
|
||||
inverter.begin_ac_charge_slot(0, rate)
|
||||
export, grid_import, _, _ = inverter.process_energy(pv_wh, 0.0, 0)
|
||||
ac_wh, _ = inverter.charge_battery_from_grid(0, rate)
|
||||
return export, grid_import + ac_wh, battery._charged_raw_wh_per_slot[0]
|
||||
|
||||
|
||||
class TestPvSurplusAboveCap:
|
||||
"""PV alone exceeds the AC setpoint - the Deye situation that exported PV."""
|
||||
|
||||
def test_total_limit_exports_pv_above_the_cap(self):
|
||||
inverter, battery = _build(limits_total=True)
|
||||
export, grid, charged_raw = _run_ac_slot(inverter, battery, pv_wh=6000, factor=0.5)
|
||||
|
||||
assert charged_raw == pytest.approx(0.5 * MAX_CHARGE_W)
|
||||
assert export == pytest.approx(6000 - 0.5 * MAX_CHARGE_W)
|
||||
assert grid == pytest.approx(0.0)
|
||||
|
||||
def test_default_model_stores_pv_up_to_full_charge_power(self):
|
||||
inverter, battery = _build(limits_total=False)
|
||||
export, grid, charged_raw = _run_ac_slot(inverter, battery, pv_wh=6000, factor=0.5)
|
||||
|
||||
assert charged_raw == pytest.approx(MAX_CHARGE_W)
|
||||
assert export == pytest.approx(6000 - MAX_CHARGE_W)
|
||||
assert grid == pytest.approx(0.0)
|
||||
|
||||
|
||||
class TestPvSurplusBelowCap:
|
||||
"""PV does not reach the setpoint - the grid tops up."""
|
||||
|
||||
def test_total_limit_grid_fills_up_to_the_cap(self):
|
||||
inverter, battery = _build(limits_total=True)
|
||||
export, grid, charged_raw = _run_ac_slot(inverter, battery, pv_wh=1000, factor=0.5)
|
||||
|
||||
assert charged_raw == pytest.approx(0.5 * MAX_CHARGE_W)
|
||||
assert grid == pytest.approx(0.5 * MAX_CHARGE_W - 1000)
|
||||
assert export == pytest.approx(0.0)
|
||||
|
||||
def test_default_model_grid_adds_factor_of_remaining_power(self):
|
||||
inverter, battery = _build(limits_total=False)
|
||||
export, grid, charged_raw = _run_ac_slot(inverter, battery, pv_wh=1000, factor=0.5)
|
||||
|
||||
assert grid == pytest.approx(0.5 * (MAX_CHARGE_W - 1000))
|
||||
assert charged_raw == pytest.approx(1000 + 0.5 * (MAX_CHARGE_W - 1000))
|
||||
assert export == pytest.approx(0.0)
|
||||
|
||||
|
||||
def test_max_ac_charge_power_caps_the_total_charge():
|
||||
"""max_ac_charge_power_w lowers the setpoint and with it the total cap."""
|
||||
inverter, battery = _build(limits_total=True, max_ac_charge_power_w=2000)
|
||||
export, grid, charged_raw = _run_ac_slot(inverter, battery, pv_wh=6000, factor=1.0)
|
||||
|
||||
assert charged_raw == pytest.approx(2000)
|
||||
assert export == pytest.approx(4000)
|
||||
assert grid == pytest.approx(0.0)
|
||||
|
||||
|
||||
def test_dc_slot_is_not_capped():
|
||||
"""Only AC slots are capped: a DC slot takes PV up to max_charge_power_w."""
|
||||
inverter, battery = _build(limits_total=True)
|
||||
battery.charge_array[0] = 1
|
||||
battery.discharge_array[0] = 0
|
||||
export, _, _, _ = inverter.process_energy(6000, 0.0, 0)
|
||||
|
||||
assert battery._charged_raw_wh_per_slot[0] == pytest.approx(MAX_CHARGE_W)
|
||||
assert export == pytest.approx(1000)
|
||||
|
||||
|
||||
def test_reset_lifts_the_slot_cap():
|
||||
inverter, battery = _build(limits_total=True)
|
||||
_run_ac_slot(inverter, battery, pv_wh=6000, factor=0.5)
|
||||
battery.reset()
|
||||
battery.charge_array = np.zeros(4)
|
||||
battery.charge_array[0] = 1
|
||||
inverter.process_energy(6000, 0.0, 0)
|
||||
|
||||
assert battery._charged_raw_wh_per_slot[0] == pytest.approx(MAX_CHARGE_W)
|
||||
|
||||
|
||||
class TestGeneticSimulation:
|
||||
"""The flag changes the simulated plan, so the optimizer can prefer DC."""
|
||||
|
||||
@pytest.fixture
|
||||
def simulate(self, config_eos):
|
||||
from akkudoktoreos.optimization.genetic.genetic import GeneticSimulation
|
||||
from akkudoktoreos.optimization.genetic.geneticparams import (
|
||||
GeneticEnergyManagementParameters,
|
||||
)
|
||||
|
||||
config_eos.merge_settings_from_dict(
|
||||
{"prediction": {"hours": 48}, "optimization": {"hours": 24}}
|
||||
)
|
||||
hours = config_eos.prediction.hours
|
||||
|
||||
def _simulate(limits_total: bool, ac_factor: float, dc_factor: float):
|
||||
battery = Battery(
|
||||
SolarPanelBatteryParameters(
|
||||
device_id="battery1",
|
||||
capacity_wh=30000,
|
||||
initial_soc_percentage=20,
|
||||
charging_efficiency=0.9,
|
||||
discharging_efficiency=0.9,
|
||||
min_soc_percentage=0,
|
||||
max_soc_percentage=100,
|
||||
max_charge_power_w=MAX_CHARGE_W,
|
||||
),
|
||||
prediction_hours=hours,
|
||||
)
|
||||
battery.reset()
|
||||
inverter = Inverter(
|
||||
InverterParameters(
|
||||
device_id="inverter1",
|
||||
max_power_wh=10000,
|
||||
battery_id="battery1",
|
||||
ac_charge_limits_total_charge=limits_total,
|
||||
),
|
||||
battery=battery,
|
||||
)
|
||||
sim = GeneticSimulation()
|
||||
sim.prepare(
|
||||
GeneticEnergyManagementParameters.model_validate(
|
||||
dict(
|
||||
pv_prognose_wh=[8000.0] * hours,
|
||||
strompreis_euro_pro_wh=[0.0003] * hours,
|
||||
einspeiseverguetung_euro_pro_wh=0.00008,
|
||||
preis_euro_pro_wh_akku=0.0001,
|
||||
gesamtlast=[0.0] * hours,
|
||||
)
|
||||
),
|
||||
optimization_hours=config_eos.optimization.genetic.horizon_hours,
|
||||
prediction_hours=hours,
|
||||
inverter=inverter,
|
||||
ev=None,
|
||||
home_appliance=None,
|
||||
)
|
||||
ac_hours, dc_hours = sim.ac_charge_hours, sim.dc_charge_hours
|
||||
discharge_hours = sim.bat_discharge_hours
|
||||
assert ac_hours is not None and dc_hours is not None and discharge_hours is not None
|
||||
ac_hours[:] = 0
|
||||
dc_hours[:] = 0
|
||||
discharge_hours[:] = 0
|
||||
ac_hours[1] = ac_factor
|
||||
dc_hours[1] = dc_factor
|
||||
return sim.simulate(start_hour=0)
|
||||
|
||||
return _simulate
|
||||
|
||||
def test_ac_slot_under_pv_surplus_exports_more_than_dc(self, simulate):
|
||||
ac = simulate(limits_total=True, ac_factor=0.5, dc_factor=0)
|
||||
dc = simulate(limits_total=True, ac_factor=0, dc_factor=1)
|
||||
|
||||
assert ac["Netzeinspeisung_Wh_pro_Stunde"][1] == pytest.approx(
|
||||
dc["Netzeinspeisung_Wh_pro_Stunde"][1] + 0.5 * MAX_CHARGE_W
|
||||
)
|
||||
assert ac["akku_soc_pro_stunde"][2] < dc["akku_soc_pro_stunde"][2]
|
||||
|
||||
def test_default_model_treats_ac_slot_like_dc_under_pv_surplus(self, simulate):
|
||||
ac = simulate(limits_total=False, ac_factor=0.5, dc_factor=0)
|
||||
dc = simulate(limits_total=False, ac_factor=0, dc_factor=1)
|
||||
|
||||
assert ac["Netzeinspeisung_Wh_pro_Stunde"][1] == pytest.approx(
|
||||
dc["Netzeinspeisung_Wh_pro_Stunde"][1]
|
||||
)
|
||||
|
||||
|
||||
class TestConfigOption:
|
||||
"""The flag is a device config option that reaches the GENETIC optimizer."""
|
||||
|
||||
@pytest.mark.parametrize("enabled", [False, True])
|
||||
def test_config_passes_the_flag_to_the_optimizer(self, enabled):
|
||||
settings = InverterCommonSettings(
|
||||
device_id="inverter",
|
||||
max_power_w=10000,
|
||||
battery_id="battery1",
|
||||
ac_charge_limits_total_charge=enabled,
|
||||
)
|
||||
assert settings.to_genetic_param().ac_charge_limits_total_charge is enabled
|
||||
|
||||
def test_default_keeps_the_existing_model(self):
|
||||
settings = InverterCommonSettings(device_id="inverter", max_power_w=10000)
|
||||
assert settings.ac_charge_limits_total_charge is False
|
||||
assert settings.to_genetic_param().ac_charge_limits_total_charge is False
|
||||
Reference in New Issue
Block a user