diff --git a/packages/control/bat_all.py b/packages/control/bat_all.py index b3ad5851d2..d553be7509 100644 --- a/packages/control/bat_all.py +++ b/packages/control/bat_all.py @@ -25,7 +25,6 @@ from control import data from control.algorithm.chargemodes import CONSIDERED_CHARGE_MODES_CHARGING from control.algorithm.filter_chargepoints import get_chargepoints_with_required_current_by_chargemode -from control.pv import Pv from helpermodules.constants import NO_ERROR from modules.common.abstract_device import AbstractDevice from modules.common.component_context import SingleComponentUpdateContext @@ -187,52 +186,35 @@ def calc_power_for_all_components(self): except Exception: log.exception("Fehler im Bat-Modul") - def get_bat_power_of_hybrid_system(self, inverter: Pv) -> float: - """ ermittelt die Leistung des Speichers, die über den Wechselrichter fließt, um die Leistung des Speichers - bei einem Hybrid-System zu berücksichtigen, wenn die maximale Ausgangsleistung des Wechselrichters erreicht ist. - """ - bat_power = 0 - try: - children = data.data.counter_all_data.get_entry_of_element(inverter.num)["children"] - if len(children): - hybrid: List[str] = [] - for c in children: - if c.get("type") == "bat": - hybrid.append(f'bat{c["id"]}') - break - if len(hybrid): - for bat in hybrid: - # nur wenn der Speicher entlädt, fließt Leistung über den WR - if data.data.bat_data[bat].data.get.power < 0: - bat_power += data.data.bat_data[bat].data.get.power - except Exception: - log.exception(f"Fehler im Bat-Modul {inverter.num}") - return bat_power - - def _limit_bat_power_discharge(self, required_power: float) -> float: - """begrenzt die für den Algorithmus benötigte Entladeleistung des Speichers, wenn die maximale Ausgangsleistung - des WR erreicht ist.""" - if required_power > 0: - # Nur wenn der Speicher entladen werden soll, fließt Leistung durch den WR. - remaining_inverter_ac_out_power = 0 - for inverter in data.data.pv_data.values(): + def _absolute_bat_discharge_power(self) -> float: + discharge_power = 0 + hybrid_bat_ids = data.data.counter_all_data.get_hybrid_bat_ids() + non_hybrid_bat_ids = data.data.counter_all_data.get_non_hybrid_bat_ids() + if len(hybrid_bat_ids) == 0: + # keine Hybrid-WR mit Speicher + discharge_power = float("inf") + else: + # nur Speicher an Hybrid-WR + discharge_power = 0 + hybrid_inverter_ids = data.data.counter_all_data.get_hybrid_inverter_ids() + for inverter_id in hybrid_inverter_ids: try: - if len(data.data.counter_all_data.get_entry_of_element(inverter.num)["children"]) == 0: - continue - bat_power = self.get_bat_power_of_hybrid_system(inverter) - inverter_power = max((inverter.data.get.power + bat_power) * -1, 0) - remaining_inverter_ac_out_power += inverter.data.config.max_ac_out - inverter_power + bat_power * -1 + inverter = data.data.pv_data[f"pv{inverter_id}"] + inverter_power = max(inverter.data.get.power * -1, 0) + discharge_power += max(inverter.data.config.max_ac_out - inverter_power, 0) except Exception: - log.exception(f"Fehler im Bat-Modul {inverter.num}") - if remaining_inverter_ac_out_power > 0: - required_power = min(required_power, remaining_inverter_ac_out_power) - log.debug( - f"Verbleibende Speicher-Leistung durch maximale Ausgangsleistung auf {required_power}W begrenzt." - ) - else: - log.debug( - "Speicher-Entladeleistung nicht durch maximale WR-Ausgangsleistung begrenzt.") - return required_power + log.exception(f"Fehler im Bat-Modul {inverter_id}") + log.debug(f"Verbleibende Speicher-Leistung durch maximale Ausgangsleistung des Wechselrichters auf " + f"{discharge_power}W begrenzt.") + if len(non_hybrid_bat_ids) > 0: + # Speicher an Hybrid-WR und AC-Speicher im System + for bat_id in non_hybrid_bat_ids: + try: + bat = data.data.bat_data[f"bat{bat_id}"] + discharge_power += bat.data.get.max_discharge_power + except Exception: + log.exception(f"Fehler im Bat-Modul {bat_id}") + return discharge_power def _set_bat_power_active_control(self, power): controllable_bat_components, _ = get_bat_components_by_controllability() @@ -326,7 +308,7 @@ def setup_bat(self): if self.data.get.fault_state == 0: self.get_power_limit() self._set_bat_power_active_control(self.data.set.power_limit) - self._get_charging_power_left() + self.get_charging_power_left_diff() log.info(f"{self.data.set.charging_power_left}W verbleibende Speicher-Leistung") else: # Bei Warnung oder Fehlerfall, zB durch Kalibrierung, Speicher-Leistung nicht in der @@ -338,8 +320,9 @@ def setup_bat(self): except Exception: log.exception("Fehler im Bat-Modul") - def _get_charging_power_left(self): - """ ermittelt die Lade-Leistung des Speichers, die zum Laden der EV verwendet werden darf. + def get_charging_power_left_diff(self): + """Ermittelt die Differenz zur aktuellen Batterie-Leistung, + die zum Laden der EV verwendet werden darf. """ try: config = data.data.general_data.data.chargemode_config.pv_charging @@ -364,6 +347,7 @@ def _get_charging_power_left(self): else: charging_power_left = self.data.get.power else: + absolute_bat_discharge_power = self._absolute_bat_discharge_power() # Speicher soll geladen werden um min SoC zu erreichen if self.data.get.soc < config.min_bat_soc: self.data.set.hysteresis_discharge = False @@ -376,12 +360,10 @@ def _get_charging_power_left(self): else: # Speicher-Vorrang bis zum Min-Soc if config.bat_power_reserve_active: - if self.data.get.power > config.bat_power_reserve: - # die Differenz darf nicht zum Laden der EV genutzt werden. - charging_power_left = self.data.get.power - config.bat_power_reserve - else: - charging_power_left = ( - config.bat_power_reserve - self.data.get.power) * -1 + # Die Differenz zwischen aktueller Batterie-Leistung und Reserveleistung bestimmt, + # was fuer EV-Ladung verbleibt (positiv) oder zusaetzlich benoetigt wird (negativ). + charging_power_left = self.data.get.power - config.bat_power_reserve + if charging_power_left < 0: self.data.set.regulate_up = True else: # Speicher wird geladen @@ -412,9 +394,16 @@ def _get_charging_power_left(self): BatPowerLimitCondition.PRICE_LIMIT.value and self.data.set.power_limit is None)) if config.bat_power_discharge_active and power_discharge_allowed: + # max Entladeleistung auf max Ausgangsleistung des WR begrenzen + required_absolut_discharge_power = min( + config.bat_power_discharge, absolute_bat_discharge_power) + # Differenz zwischen erlaubter Entladeleistung und aktueller Speicherleistung bestimmen. + # Wenn der Speicher lädt, darf die freigegebene Entladeleistung nicht die max + # Ausgangsleistung des WR überschreiten. # Wenn der Speicher mit mehr als der erlaubten Entladeleistung entladen wird, muss das # vom Überschuss subtrahiert werden. - charging_power_left = config.bat_power_discharge + base_power + charging_power_left = min(required_absolut_discharge_power + base_power, + absolute_bat_discharge_power) log.debug(f"Erlaubte Entlade-Leistung nutzen {charging_power_left}W") else: # Speicher sollte weder ge- noch entladen werden. @@ -442,9 +431,16 @@ def _get_charging_power_left(self): BatPowerLimitCondition.PRICE_LIMIT.value and self.data.set.power_limit is None)) if config.bat_power_discharge_active and power_discharge_allowed: + # max Entladeleistung auf max Ausgangsleistung des WR begrenzen + required_absolut_discharge_power = min( + config.bat_power_discharge, absolute_bat_discharge_power) + # Differenz zwischen erlaubter Entladeleistung und aktueller Speicherleistung bestimmen. + # Wenn der Speicher lädt, darf die freigegebene Entladeleistung nicht die max + # Ausgangsleistung des WR überschreiten. # Wenn der Speicher mit mehr als der erlaubten Entladeleistung entladen wird, muss das # vom Überschuss subtrahiert werden. - charging_power_left = config.bat_power_discharge + base_power + charging_power_left = min(required_absolut_discharge_power + base_power, + absolute_bat_discharge_power) log.debug(f"Erlaubte Entlade-Leistung nutzen {charging_power_left}W") else: # Speicher sollte weder ge- noch entladen werden. @@ -461,7 +457,7 @@ def _get_charging_power_left(self): log.debug("Damit der Speicher hochregeln kann, muss unabhängig vom eingestellten Regelmodus " "Einspeisung erzeugt werden.") charging_power_left -= 100 - self.data.set.charging_power_left = self._limit_bat_power_discharge(charging_power_left) + self.data.set.charging_power_left = charging_power_left except Exception: log.exception("Fehler im Bat-Modul") diff --git a/packages/control/bat_all_test.py b/packages/control/bat_all_test.py index 245a1f9917..38863ccb92 100644 --- a/packages/control/bat_all_test.py +++ b/packages/control/bat_all_test.py @@ -6,7 +6,7 @@ from control import bat_all from control.bat import Bat -from control.bat_all import BatAll, BatPowerLimitMode, BatPowerLimitCondition, ManualMode +from control.bat_all import BatAll, BatConsiderationMode, BatPowerLimitMode, BatPowerLimitCondition, ManualMode from control import data from control.chargepoint.chargepoint import Chargepoint from control.chargepoint.chargepoint_all import AllChargepointData, AllChargepoints, AllGet @@ -27,55 +27,73 @@ def data_fixture() -> None: @pytest.mark.parametrize( - "bat_power, required_power, pv_power, expected_power", + "bat_power, pv_power, expected_power", [ - pytest.param(-1000, 1000, 0, 1000, id="Leistung verfügbar"), - pytest.param(-4900, 5100, -100, 4900, id="max Leistung des WR um 100W überschritten, Speicher entlädt"), - pytest.param(1000, 1600, -4500, 500, id="Speicher lädt, soll entladen"), + pytest.param(-1000, 0, 4000, id="Leistung verfügbar"), + pytest.param(-4900, -100, 0, id="max Leistung des WR um 100W überschritten, Speicher entlädt"), + pytest.param(1000, -4500, 500, id="Speicher lädt, soll entladen"), ]) -def test_limit_bat_power_discharge(bat_power: int, - required_power: int, - pv_power: int, - expected_power: int, - monkeypatch: pytest.MonkeyPatch): +def test_get_charging_power_left_diff_hybrid(bat_power: int, + pv_power: int, + expected_power: int, + monkeypatch: pytest.MonkeyPatch): # setup data.data.pv_data = {"pv2": Pv(2)} data.data.pv_data["pv2"].data.get.power = pv_power data.data.pv_data["pv2"].data.config.max_ac_out = 5000 data.data.bat_data["bat1"] = Bat(1) data.data.bat_data["bat1"].data.get.power = bat_power - mock_entry_children = Mock(return_value={"id": 5, "type": "pv", "children": [ - {"id": 1, "type": "bat", "children": []}]}) - monkeypatch.setattr(data.data.counter_all_data, "get_entry_of_element", mock_entry_children) + data.data.bat_data["bat1"].data.get.soc = 71 + data.data.general_data.data.chargemode_config.pv_charging.bat_mode = BatConsiderationMode.MIN_SOC_BAT.value + data.data.general_data.data.chargemode_config.pv_charging.bat_power_discharge = 5000 + data.data.general_data.data.chargemode_config.pv_charging.bat_power_discharge_active = True + monkeypatch.setattr(data.data.counter_all_data, "get_hybrid_bat_ids", Mock(return_value=[1])) + monkeypatch.setattr(data.data.counter_all_data, "get_non_hybrid_bat_ids", Mock(return_value=[])) + monkeypatch.setattr(data.data.counter_all_data, "get_hybrid_inverter_ids", Mock(return_value=[2])) - b = BatAll() - b.data.get.power = bat_power + b_all = BatAll() + b_all.data.get.power = bat_power + b_all.data.get.soc = 71 # execution - power = b._limit_bat_power_discharge(required_power) # pyright: ignore[reportPrivateUsage] + b_all.get_charging_power_left_diff() # evaluation - assert power == expected_power + assert b_all.data.set.charging_power_left == expected_power -def test_limit_bat_power_discharge_no_hybrid_system(monkeypatch: pytest.MonkeyPatch): +@pytest.mark.parametrize( + "hybrid_bat_ids, non_hybrid_bat_ids, hybrid_inverter_ids, expected_power", + [ + pytest.param([], [1], [], float("inf"), id="no hybrid"), + pytest.param([1], [], [2], 4900, id="hybrid,"), + pytest.param([1], [3], [2], 10900, id="hybrid an non hybrid bat"), + ]) +def test__absolute_bat_discharge_power(hybrid_bat_ids: List[int], + non_hybrid_bat_ids: List[int], + hybrid_inverter_ids: List[int], + expected_power: float, + monkeypatch: pytest.MonkeyPatch): # setup data.data.pv_data = {"pv2": Pv(2)} data.data.pv_data["pv2"].data.get.power = -100 data.data.pv_data["pv2"].data.config.max_ac_out = 5000 data.data.bat_data["bat1"] = Bat(1) data.data.bat_data["bat1"].data.get.power = -4900 - mock_entry_children = Mock(return_value={"id": 5, "type": "pv", "children": []}) - monkeypatch.setattr(data.data.counter_all_data, "get_entry_of_element", mock_entry_children) + data.data.bat_data["bat3"] = Bat(3) + data.data.bat_data["bat3"].data.get.max_discharge_power = 6000 + monkeypatch.setattr(data.data.counter_all_data, "get_hybrid_bat_ids", Mock(return_value=hybrid_bat_ids)) + monkeypatch.setattr(data.data.counter_all_data, "get_non_hybrid_bat_ids", Mock(return_value=non_hybrid_bat_ids)) + monkeypatch.setattr(data.data.counter_all_data, "get_hybrid_inverter_ids", Mock(return_value=hybrid_inverter_ids)) b = BatAll() b.data.get.power = -4900 # execution - power = b._limit_bat_power_discharge(5100) # pyright: ignore[reportPrivateUsage] + power = b._absolute_bat_discharge_power() # pyright: ignore[reportPrivateUsage] # evaluation - assert power == 5100 + assert power == expected_power @dataclass @@ -159,17 +177,51 @@ def test_get_charging_power_left(params: Params, caplog, data_, monkeypatch): b.data.get.power = params.power data.data.bat_data["bat0"] = b data.data.general_data.data.chargemode_config.pv_charging = params.config - mock_limit_bat_power_discharge = MagicMock(side_effect=lambda x: x) - monkeypatch.setattr(BatAll, "_limit_bat_power_discharge", mock_limit_bat_power_discharge) + mock_absolute_bat_discharge_power = MagicMock(return_value=10000) + monkeypatch.setattr(BatAll, "_absolute_bat_discharge_power", mock_absolute_bat_discharge_power) # execution - b_all._get_charging_power_left() + b_all.get_charging_power_left_diff() # evaluation assert b_all.data.set.charging_power_left == params.expected_charging_power_left assert b_all.data.set.regulate_up == params.expected_regulate_up +def test_get_charging_power_left_uses_limited_bat_discharge_in_hysteresis( + data_: data.Data, monkeypatch: pytest.MonkeyPatch): + # setup: min/max-SoC-Bereich mit aktiver Hysterese und erlaubter Entladeleistung + b_all = BatAll() + b_all.data.get.power = -2500 + b_all.data.get.soc = 60 + b_all.data.set.hysteresis_discharge = True + b_all.data.set.power_limit = None + data.data.general_data.data.chargemode_config.pv_charging = PvCharging( + bat_mode="min_soc_bat_mode", + min_bat_soc=40, + max_bat_soc=80, + bat_power_discharge=8000, + bat_power_discharge_active=True, + ) + + # Hybrid-Setup fuer reale Berechnung in _limit_bat_power_discharge + data.data.pv_data = {"pv2": Pv(2)} + data.data.pv_data["pv2"].data.get.power = -7500 + data.data.pv_data["pv2"].data.config.max_ac_out = 10000 + data.data.bat_data["bat1"] = Bat(1) + data.data.bat_data["bat1"].data.get.power = -2500 + monkeypatch.setattr(data.data.counter_all_data, "get_hybrid_bat_ids", Mock(return_value=[1])) + monkeypatch.setattr(data.data.counter_all_data, "get_non_hybrid_bat_ids", Mock(return_value=[])) + monkeypatch.setattr(data.data.counter_all_data, "get_hybrid_inverter_ids", Mock(return_value=[2])) + + # execution + b_all.get_charging_power_left_diff() + + # evaluation: reale Begrenzung (300W) + base_power (400W) + assert b_all.data.set.charging_power_left == 0 + assert b_all.data.set.regulate_up is False + + def default_chargepoint_factory() -> List[Chargepoint]: cp = Chargepoint(3, None) cp.data.get.power = 1400 diff --git a/packages/control/counter_all.py b/packages/control/counter_all.py index 89f214b773..e299129839 100644 --- a/packages/control/counter_all.py +++ b/packages/control/counter_all.py @@ -418,6 +418,40 @@ def _get_list_of_elements_per_level(self, elements_per_level: List, child: Dict, elements_per_level = self._get_list_of_elements_per_level(elements_per_level, child, index+1) return elements_per_level + def get_hybrid_inverter_ids(self) -> List[int]: + inverter_ids = [] + + def collect_hybrid_inverter_ids(elements: List[Dict]): + for element in elements: + if element["type"] == ComponentType.INVERTER.value: + if any(child.get("type") == ComponentType.BAT.value for child in element.get("children", [])): + inverter_ids.append(element["id"]) + collect_hybrid_inverter_ids(element.get("children", [])) + + collect_hybrid_inverter_ids(self.data.get.hierarchy) + return inverter_ids + + def _get_bat_ids_by_hybrid_type(self, hybrid: bool) -> List[int]: + bat_ids = [] + + def collect_bat_ids(elements: List[Dict], parent_type: Optional[str] = None): + for element in elements: + element_type = element.get("type") + if element_type == ComponentType.BAT.value: + is_hybrid_bat = parent_type == ComponentType.INVERTER.value + if is_hybrid_bat == hybrid: + bat_ids.append(element["id"]) + collect_bat_ids(element.get("children", []), element_type) + + collect_bat_ids(self.data.get.hierarchy) + return bat_ids + + def get_hybrid_bat_ids(self) -> List[int]: + return self._get_bat_ids_by_hybrid_type(hybrid=True) + + def get_non_hybrid_bat_ids(self) -> List[int]: + return self._get_bat_ids_by_hybrid_type(hybrid=False) + def validate_hierarchy(self): try: self._delete_obsolete_entries() diff --git a/packages/control/hierarchy_test.py b/packages/control/hierarchy_test.py index 297a9d7a3b..7fa28a3f10 100644 --- a/packages/control/hierarchy_test.py +++ b/packages/control/hierarchy_test.py @@ -1,7 +1,7 @@ from typing import Dict, List, Optional, Union from unittest.mock import Mock import pytest -from packages.conftest import hierarchy_hybrid, hierarchy_nested +from packages.conftest import hierarchy_hybrid, hierarchy_nested, hierarchy_standard from control.counter import Counter @@ -399,3 +399,54 @@ def test_add_missing_entries(hierarchy, expected_hierarchy, data_, monkeypatch): # evaluation assert counter_all.data.get.hierarchy == expected_hierarchy + + +@pytest.mark.parametrize("hierarchy, expected_ids", + [ + pytest.param(hierarchy_hybrid(), [2], id="hybrid"), + pytest.param(hierarchy_standard(), [], id="standard"), + ] + ) +def test_get_hybrid_bat_ids(hierarchy: CounterAll, expected_ids: List[int]): + # setup + counter_all = hierarchy + + # execution + hybrid_bat_ids = counter_all.get_hybrid_bat_ids() + + # evaluation + assert hybrid_bat_ids == expected_ids + + +@pytest.mark.parametrize("hierarchy, expected_ids", + [ + pytest.param(hierarchy_hybrid(), [], id="hybrid"), + pytest.param(hierarchy_standard(), [2], id="standard"), + ] + ) +def test_get_non_hybrid_bat_ids(hierarchy: CounterAll, expected_ids: List[int]): + # setup + counter_all = hierarchy + + # execution + non_hybrid_bat_ids = counter_all.get_non_hybrid_bat_ids() + + # evaluation + assert non_hybrid_bat_ids == expected_ids + + +@pytest.mark.parametrize("hierarchy, expected_ids", + [ + pytest.param(hierarchy_hybrid(), [1], id="hybrid"), + pytest.param(hierarchy_standard(), [], id="standard"), + ] + ) +def test_get_hybrid_inverter_ids(hierarchy: CounterAll, expected_ids: List[int]): + # setup + counter_all = hierarchy + + # execution + hybrid_inverter_ids = counter_all.get_hybrid_inverter_ids() + + # evaluation + assert hybrid_inverter_ids == expected_ids