diff --git a/packages/control/algorithm/algorithm.py b/packages/control/algorithm/algorithm.py index 0c15fdf855..4c4ed46aa5 100644 --- a/packages/control/algorithm/algorithm.py +++ b/packages/control/algorithm/algorithm.py @@ -4,6 +4,7 @@ from control import data from control.algorithm import common from control.algorithm.additional_current import AdditionalCurrent +from control.algorithm.bidi_charging import Bidi from control.algorithm.min_current import MinCurrent from control.algorithm.no_current import NoCurrent from control.algorithm.surplus_controlled import SurplusControlled @@ -14,6 +15,7 @@ class Algorithm: def __init__(self): self.additional_current = AdditionalCurrent() + self.bidi = Bidi() self.min_current = MinCurrent() self.no_current = NoCurrent() self.surplus_controlled = SurplusControlled() @@ -32,14 +34,17 @@ def calc_current(self) -> None: log.info("**Soll-Strom setzen**") common.reset_current_to_target_current() self.additional_current.set_additional_current() + self.surplus_controlled.set_required_current_to_max() + log.info("**PV-geführten Strom setzen**") counter.limit_raw_power_left_to_surplus(self.evu_counter.calc_raw_surplus()) if self.evu_counter.data.set.surplus_power_left > 0: - log.info("**PV-geführten Strom setzen**") common.reset_current_to_target_current() - self.surplus_controlled.set_required_current_to_max() self.surplus_controlled.set_surplus_current() else: - log.info("**Keine Leistung für PV-geführtes Laden übrig.**") + log.info("Keine Leistung für PV-geführtes Laden übrig.") + log.info("**Bidi-(Ent-)Lade-Strom setzen**") + counter.set_raw_surplus_power_left() + self.bidi.set_bidi() self.no_current.set_no_current() self.no_current.set_none_current() except Exception: diff --git a/packages/control/algorithm/bidi_charging.py b/packages/control/algorithm/bidi_charging.py new file mode 100644 index 0000000000..b30e3a65f7 --- /dev/null +++ b/packages/control/algorithm/bidi_charging.py @@ -0,0 +1,45 @@ +import logging +from control import data +from control.algorithm.chargemodes import CONSIDERED_CHARGE_MODES_BIDI_DISCHARGE +from control.algorithm.filter_chargepoints import get_chargepoints_by_mode + +log = logging.getLogger(__name__) + + +class Bidi: + def __init__(self): + pass + + def set_bidi(self): + grid_counter = data.data.counter_all_data.get_evu_counter() + log.debug(f"Nullpunktanpassung {grid_counter.data.set.surplus_power_left}W") + zero_point_adjustment = grid_counter + for mode_tuple in CONSIDERED_CHARGE_MODES_BIDI_DISCHARGE: + preferenced_cps = get_chargepoints_by_mode(mode_tuple) + if preferenced_cps: + log.info( + f"Mode-Tuple {mode_tuple[0]} - {mode_tuple[1]} - {mode_tuple[2]}, Zähler {grid_counter.num}") + while len(preferenced_cps): + cp = preferenced_cps[0] + zero_point_adjustment = grid_counter.data.set.surplus_power_left / len(preferenced_cps) + log.debug(f"Nullpunktanpassung für LP{cp.num}: verbleibende Leistung {zero_point_adjustment}W") + missing_currents = [zero_point_adjustment / cp.data.get.phases_in_use / + 230 for i in range(0, cp.data.get.phases_in_use)] + missing_currents += [0] * (3 - len(missing_currents)) + if zero_point_adjustment > 0: + if cp.data.set.charging_ev_data.charge_template.bidi_charging_allowed( + cp.data.control_parameter.current_plan, cp.data.set.charging_ev_data.data.get.soc): + for index in range(0, 3): + missing_currents[index] = min(cp.data.control_parameter.required_current, + missing_currents[index]) + else: + log.info(f"LP{cp.num}: Nur bidirektional entladen erlaubt, da SoC-Limit erreicht.") + missing_currents = [0, 0, 0] + else: + for index in range(0, 3): + missing_currents[index] = cp.check_min_max_current(missing_currents[index], + cp.data.get.phases_in_use) + grid_counter.update_surplus_values_left(missing_currents, cp.data.get.voltages) + cp.data.set.current = missing_currents[0] + log.info(f"LP{cp.num}: Stromstärke {missing_currents}A") + preferenced_cps.pop(0) diff --git a/packages/control/algorithm/chargemodes.py b/packages/control/algorithm/chargemodes.py index 15036df58d..0ebfa07cf3 100644 --- a/packages/control/algorithm/chargemodes.py +++ b/packages/control/algorithm/chargemodes.py @@ -18,12 +18,16 @@ (Chargemode.ECO_CHARGING, Chargemode.PV_CHARGING, False), (Chargemode.PV_CHARGING, Chargemode.PV_CHARGING, True), (Chargemode.PV_CHARGING, Chargemode.PV_CHARGING, False), + # niedrigere Priorität soll nachrangig geladen, aber zuerst entladen werden + (Chargemode.SCHEDULED_CHARGING, Chargemode.BIDI_CHARGING, False), + (Chargemode.SCHEDULED_CHARGING, Chargemode.BIDI_CHARGING, True), (None, Chargemode.STOP, True), (None, Chargemode.STOP, False)) CONSIDERED_CHARGE_MODES_SURPLUS = CHARGEMODES[0:2] + CHARGEMODES[6:16] CONSIDERED_CHARGE_MODES_PV_ONLY = CHARGEMODES[10:16] CONSIDERED_CHARGE_MODES_ADDITIONAL_CURRENT = CHARGEMODES[0:10] -CONSIDERED_CHARGE_MODES_MIN_CURRENT = CHARGEMODES[0:-1] -CONSIDERED_CHARGE_MODES_NO_CURRENT = CHARGEMODES[16:18] +CONSIDERED_CHARGE_MODES_MIN_CURRENT = CHARGEMODES[0:-4] +CONSIDERED_CHARGE_MODES_NO_CURRENT = CHARGEMODES[18:20] +CONSIDERED_CHARGE_MODES_BIDI_DISCHARGE = CHARGEMODES[16:18] CONSIDERED_CHARGE_MODES_CHARGING = CHARGEMODES[0:16] diff --git a/packages/control/algorithm/integration_test/bidi_charging_test.py b/packages/control/algorithm/integration_test/bidi_charging_test.py new file mode 100644 index 0000000000..0a065ac734 --- /dev/null +++ b/packages/control/algorithm/integration_test/bidi_charging_test.py @@ -0,0 +1,59 @@ +import pytest +from unittest.mock import Mock + +from control import data +from control.algorithm.algorithm import Algorithm +from control.chargemode import Chargemode + + +@pytest.fixture() +def bidi_cps(): + def _setup(*cps): + for cp in cps: + data.data.cp_data[cp].data.get.max_discharge_power = -11000 + data.data.cp_data[cp].data.get.max_charge_power = 11000 + data.data.cp_data[cp].data.get.phases_in_use = 3 + control_parameter = data.data.cp_data[cp].data.control_parameter + control_parameter.min_current = data.data.cp_data[cp].data.set.charging_ev_data.ev_template.data.min_current + control_parameter.phases = 3 + control_parameter.required_currents = [16]*3 + control_parameter.required_current = 16 + control_parameter.chargemode = Chargemode.SCHEDULED_CHARGING + control_parameter.submode = Chargemode.BIDI_CHARGING + return _setup + + +@pytest.mark.parametrize("grid_power, expected_current", + [pytest.param(-2000, 2.898550724637681, id="bidi charge"), + pytest.param(2000, -2.898550724637681, id="bidi discharge")]) +def test_cp3_bidi(grid_power: float, expected_current: float, bidi_cps, all_cp_not_charging, monkeypatch): + # setup + bidi_cps("cp3") + data.data.counter_data["counter0"].data.get.power = grid_power + return_mock = Mock(reurn_value=True) + monkeypatch.setattr( + data.data.cp_data["cp3"].data.set.charging_ev_data.charge_template, "bidi_charging_allowed", return_mock) + + # execution + Algorithm().calc_current() + + # evaluation + assert data.data.cp_data["cp3"].data.set.current == expected_current + assert data.data.cp_data["cp4"].data.set.current == 0 + assert data.data.cp_data["cp5"].data.set.current == 0 + assert data.data.counter_data["counter0"].data.set.surplus_power_left == 0 + + +def test_cp3_cp4_bidi_discharge(bidi_cps, all_cp_not_charging, monkeypatch): + # setup + bidi_cps("cp3", "cp4") + data.data.counter_data["counter0"].data.get.power = 4000 + + # execution + Algorithm().calc_current() + + # evaluation + assert data.data.cp_data["cp3"].data.set.current == -2.898550724637681 + assert data.data.cp_data["cp4"].data.set.current == -2.898550724637681 + assert data.data.cp_data["cp5"].data.set.current == 0 + assert data.data.counter_data["counter0"].data.set.surplus_power_left == 0 diff --git a/packages/control/algorithm/integration_test/conftest.py b/packages/control/algorithm/integration_test/conftest.py index 0f9d86433c..22eac7fd59 100644 --- a/packages/control/algorithm/integration_test/conftest.py +++ b/packages/control/algorithm/integration_test/conftest.py @@ -7,6 +7,7 @@ from control.bat import Bat from control.bat_all import BatAll from control.chargepoint.chargepoint import Chargepoint +from control.chargepoint.chargepoint_template import CpTemplate from control.counter_all import CounterAll from control.counter import Counter from control.ev.ev import Ev @@ -24,6 +25,7 @@ def data_() -> None: "cp4": Chargepoint(4, None), "cp5": Chargepoint(5, None)} for i in range(3, 6): + data.data.cp_data[f"cp{i}"].template = CpTemplate() data.data.cp_data[f"cp{i}"].data.config.phase_1 = i-2 data.data.cp_data[f"cp{i}"].data.set.charging_ev = i data.data.cp_data[f"cp{i}"].data.set.charging_ev_data = Ev(i) diff --git a/packages/control/algorithm/integration_test/pv_charging_test.py b/packages/control/algorithm/integration_test/pv_charging_test.py index 2147814339..c21eae69dc 100644 --- a/packages/control/algorithm/integration_test/pv_charging_test.py +++ b/packages/control/algorithm/integration_test/pv_charging_test.py @@ -133,7 +133,7 @@ def test_start_pv_delay(all_cp_pv_charging_3p, all_cp_not_charging, monkeypatch) assert data.data.cp_data[ "cp5"].data.control_parameter.timestamp_switch_on_off == 1652683252.0 assert data.data.counter_data["counter0"].data.set.raw_power_left == 31975 - assert data.data.counter_data["counter0"].data.set.surplus_power_left == 10090.0 + assert data.data.counter_data["counter0"].data.set.surplus_power_left == -690 assert data.data.counter_data["counter0"].data.set.reserved_surplus == 9000 @@ -170,7 +170,7 @@ def test_pv_delay_expired(all_cp_pv_charging_3p, all_cp_not_charging, monkeypatc assert data.data.cp_data[ "cp5"].data.control_parameter.timestamp_switch_on_off is None assert data.data.counter_data["counter0"].data.set.raw_power_left == 24300 - assert data.data.counter_data["counter0"].data.set.surplus_power_left == 2415 + assert data.data.counter_data["counter0"].data.set.surplus_power_left == -690 assert data.data.counter_data["counter0"].data.set.reserved_surplus == 0 @@ -184,7 +184,7 @@ def test_pv_delay_expired(all_cp_pv_charging_3p, all_cp_not_charging, monkeypatc expected_current_cp4=8, expected_current_cp5=8, expected_raw_power_left=34820, - expected_surplus_power_left=6035.0, + expected_surplus_power_left=1090, expected_reserved_surplus=0, expected_released_surplus=0), ParamsSurplus(name="reduce current", @@ -196,7 +196,7 @@ def test_pv_delay_expired(all_cp_pv_charging_3p, all_cp_not_charging, monkeypatc expected_current_cp4=7.8731884057971016, expected_current_cp5=7.8731884057971016, expected_raw_power_left=24470, - expected_surplus_power_left=0, + expected_surplus_power_left=1090, expected_reserved_surplus=0, expected_released_surplus=0), ParamsSurplus(name="switch off delay for two of three charging", @@ -208,7 +208,7 @@ def test_pv_delay_expired(all_cp_pv_charging_3p, all_cp_not_charging, monkeypatc expected_current_cp4=6, expected_current_cp5=6, expected_raw_power_left=5635, - expected_surplus_power_left=-16250.0, + expected_surplus_power_left=-8200, expected_reserved_surplus=0, expected_released_surplus=11040), ] @@ -247,7 +247,7 @@ def test_surplus(params: ParamsSurplus, all_cp_pv_charging_3p, all_cp_charging_3 expected_current_cp4=6, expected_current_cp5=6, expected_raw_power_left=17400, - expected_surplus_power_left=-4485, + expected_surplus_power_left=-690, expected_reserved_surplus=0, expected_released_surplus=0), ParamsPhaseSwitch(name="phase switch 1p->3p", @@ -261,7 +261,7 @@ def test_surplus(params: ParamsSurplus, all_cp_pv_charging_3p, all_cp_charging_3 expected_current_cp4=6, expected_current_cp5=6, expected_raw_power_left=37520.0, - expected_surplus_power_left=10575.0, + expected_surplus_power_left=3000, expected_reserved_surplus=0, expected_released_surplus=0) ] diff --git a/packages/control/algorithm/surplus_controlled.py b/packages/control/algorithm/surplus_controlled.py index b5a45aad97..d4ceadca27 100644 --- a/packages/control/algorithm/surplus_controlled.py +++ b/packages/control/algorithm/surplus_controlled.py @@ -3,7 +3,8 @@ from control import data from control.algorithm import common -from control.algorithm.chargemodes import CONSIDERED_CHARGE_MODES_PV_ONLY, CONSIDERED_CHARGE_MODES_SURPLUS +from control.algorithm.chargemodes import (CONSIDERED_CHARGE_MODES_BIDI_DISCHARGE, CONSIDERED_CHARGE_MODES_PV_ONLY, + CONSIDERED_CHARGE_MODES_SURPLUS) from control.algorithm.filter_chargepoints import (get_chargepoints_by_chargemodes, get_chargepoints_by_mode_and_counter, get_preferenced_chargepoint_charging) @@ -76,7 +77,7 @@ def _set(self, current = common.get_current_to_set(cp.data.set.current, current, cp.data.set.target_current) self._set_loadmangement_message(current, limit, cp) - limited_current = self._limit_adjust_current(cp, current) + limited_current = limit_adjust_current(cp, current) common.set_current_counterdiff( cp.data.control_parameter.min_current, limited_current, @@ -105,35 +106,6 @@ def filter_by_feed_in_limit(self, chargepoints: List[Chargepoint]) -> Tuple[List pv_charging.feed_in_limit is False, chargepoints)) return cp_with_feed_in, cp_without_feed_in - # tested - def _limit_adjust_current(self, chargepoint: Chargepoint, new_current: float) -> float: - if chargepoint.template.data.charging_type == ChargingType.AC.value: - MAX_CURRENT = 5 - else: - MAX_CURRENT = 30 - msg = None - nominal_difference = chargepoint.data.set.charging_ev_data.ev_template.data.nominal_difference - if chargepoint.chargemode_changed or chargepoint.data.get.charge_state is False: - return new_current - else: - # Um max. +/- 5A pro Zyklus regeln - if (-MAX_CURRENT-nominal_difference - < new_current - get_medium_charging_current(chargepoint.data.get.currents) - < MAX_CURRENT+nominal_difference): - current = new_current - else: - if new_current < get_medium_charging_current(chargepoint.data.get.currents): - current = get_medium_charging_current(chargepoint.data.get.currents) - MAX_CURRENT - msg = f"Es darf um max {MAX_CURRENT}A unter den aktuell genutzten Strom geregelt werden." - - else: - current = get_medium_charging_current(chargepoint.data.get.currents) + MAX_CURRENT - msg = f"Es darf um max {MAX_CURRENT}A über den aktuell genutzten Strom geregelt werden." - chargepoint.set_state_and_log(msg) - return max(current, - chargepoint.data.control_parameter.min_current, - chargepoint.data.set.target_current) - def _fix_deviating_evse_current(self, chargepoint: Chargepoint) -> float: """Wenn Autos nicht die volle Ladeleistung nutzen, wird unnötig eingespeist. Dann kann um den noch nicht genutzten Soll-Strom hochgeregelt werden. Wenn Fahrzeuge entgegen der Norm mehr Ladeleistung beziehen, als @@ -182,7 +154,8 @@ def phase_switch_necessary() -> bool: log.exception(f"Fehler in der PV-gesteuerten Ladung bei {cp.num}") def set_required_current_to_max(self) -> None: - for cp in get_chargepoints_by_chargemodes(CONSIDERED_CHARGE_MODES_SURPLUS): + for cp in get_chargepoints_by_chargemodes(CONSIDERED_CHARGE_MODES_SURPLUS + + CONSIDERED_CHARGE_MODES_BIDI_DISCHARGE): try: charging_ev_data = cp.data.set.charging_ev_data required_currents = cp.data.control_parameter.required_currents @@ -206,3 +179,33 @@ def set_required_current_to_max(self) -> None: control_parameter.required_current = max_current except Exception: log.exception(f"Fehler in der PV-gesteuerten Ladung bei {cp.num}") + + +# tested +def limit_adjust_current(chargepoint: Chargepoint, new_current: float) -> float: + if chargepoint.template.data.charging_type == ChargingType.AC.value: + MAX_CURRENT = 5 + else: + MAX_CURRENT = 30 + msg = None + nominal_difference = chargepoint.data.set.charging_ev_data.ev_template.data.nominal_difference + if chargepoint.chargemode_changed or chargepoint.data.get.charge_state is False: + return new_current + else: + # Um max. +/- 5A pro Zyklus regeln + if (-MAX_CURRENT-nominal_difference + < new_current - get_medium_charging_current(chargepoint.data.get.currents) + < MAX_CURRENT+nominal_difference): + current = new_current + else: + if new_current < get_medium_charging_current(chargepoint.data.get.currents): + current = get_medium_charging_current(chargepoint.data.get.currents) - MAX_CURRENT + msg = f"Es darf um max {MAX_CURRENT}A unter den aktuell genutzten Strom geregelt werden." + + else: + current = get_medium_charging_current(chargepoint.data.get.currents) + MAX_CURRENT + msg = f"Es darf um max {MAX_CURRENT}A über den aktuell genutzten Strom geregelt werden." + chargepoint.set_state_and_log(msg) + return max(current, + chargepoint.data.control_parameter.min_current, + chargepoint.data.set.target_current) diff --git a/packages/control/algorithm/surplus_controlled_test.py b/packages/control/algorithm/surplus_controlled_test.py index d9eb6411f2..5963e6d491 100644 --- a/packages/control/algorithm/surplus_controlled_test.py +++ b/packages/control/algorithm/surplus_controlled_test.py @@ -5,7 +5,8 @@ from control import data from control.algorithm import surplus_controlled from control.algorithm.filter_chargepoints import get_chargepoints_by_chargemodes -from control.algorithm.surplus_controlled import CONSIDERED_CHARGE_MODES_PV_ONLY, SurplusControlled +from control.algorithm.surplus_controlled import (CONSIDERED_CHARGE_MODES_PV_ONLY, SurplusControlled, + limit_adjust_current) from control.chargemode import Chargemode from control.chargepoint.chargepoint import Chargepoint, ChargepointData from control.chargepoint.chargepoint_data import Get, Set @@ -66,7 +67,7 @@ def test_limit_adjust_current(new_current: float, expected_current: float, monke monkeypatch.setattr(Chargepoint, "set_state_and_log", Mock()) # execution - current = SurplusControlled()._limit_adjust_current(cp, new_current) + current = limit_adjust_current(cp, new_current) # evaluation assert current == expected_current diff --git a/packages/control/bat_all.py b/packages/control/bat_all.py index edcab10bae..ac0cbdc90e 100644 --- a/packages/control/bat_all.py +++ b/packages/control/bat_all.py @@ -209,6 +209,7 @@ def _get_charging_power_left(self): else: charging_power_left = 0 self.data.set.regulate_up = True if self.data.get.soc < 100 else False + # ev wird nach Speicher geladen elif config.bat_mode == BatConsiderationMode.EV_MODE.value: # Speicher sollte weder ge- noch entladen werden. charging_power_left = self.data.get.power diff --git a/packages/control/chargemode.py b/packages/control/chargemode.py index c9a7e41e88..4ff3dfea22 100644 --- a/packages/control/chargemode.py +++ b/packages/control/chargemode.py @@ -7,4 +7,5 @@ class Chargemode(Enum): INSTANT_CHARGING = "instant_charging" PV_CHARGING = "pv_charging" ECO_CHARGING = "eco_charging" + BIDI_CHARGING = "bidi_charging" STOP = "stop" diff --git a/packages/control/chargepoint/chargepoint.py b/packages/control/chargepoint/chargepoint.py index b901223bfe..385ae5033c 100644 --- a/packages/control/chargepoint/chargepoint.py +++ b/packages/control/chargepoint/chargepoint.py @@ -34,11 +34,13 @@ from control.ev.ev import Ev from control import phase_switch from control.chargepoint.chargepoint_state import CHARGING_STATES, ChargepointState +from control.text import BidiState from helpermodules.broker import BrokerClient from helpermodules.phase_mapping import convert_single_evu_phase_to_cp_phase from helpermodules.pub import Pub from helpermodules import timecheck from helpermodules.utils import thread_handler +from modules.chargepoints.openwb_pro.chargepoint_module import EvseSignaling from modules.common.abstract_chargepoint import AbstractChargepoint from helpermodules.timecheck import check_timestamp, create_timestamp @@ -246,7 +248,7 @@ def setup_values_at_start(self): self._reset_values_at_start() self._set_values_at_start() - def set_control_parameter(self, submode: str, required_current: float): + def set_control_parameter(self, submode: str): """ setzt die Regel-Parameter, die der Algorithmus verwendet. Parameter @@ -262,7 +264,6 @@ def set_control_parameter(self, submode: str, required_current: float): self.data.control_parameter.chargemode = Chargemode( self.data.set.charge_template.data.chargemode.selected) self.data.control_parameter.prio = self.data.set.charge_template.data.prio - self.data.control_parameter.required_current = required_current if self.template.data.charging_type == ChargingType.AC.value: self.data.control_parameter.min_current = self.data.set.charging_ev_data.ev_template.data.min_current else: @@ -337,6 +338,8 @@ def check_deviating_contactor_states(self, phase_a: int, phase_b: int) -> bool: def _is_phase_switch_required(self) -> bool: phase_switch_required = False + if self.data.get.evse_signaling == EvseSignaling.HLC: + return False if (self.data.control_parameter.state == ChargepointState.WAIT_FOR_USING_PHASES and (self.data.set.current != 0 and self.data.set.current_prev != 0)): phase_switch_required = False @@ -441,6 +444,8 @@ def initiate_phase_switch(self): """prüft, ob eine Phasenumschaltung erforderlich ist und führt diese durch. """ try: + if self.data.get.evse_signaling == EvseSignaling.HLC: + return evu_counter = data.data.counter_all_data.get_evu_counter() charging_ev = self.data.set.charging_ev_data # Wenn noch kein Eintrag im Protokoll erstellt wurde, wurde noch nicht geladen und die Phase kann noch @@ -498,7 +503,9 @@ def initiate_phase_switch(self): def get_phases_by_selected_chargemode(self, phases_chargemode: int) -> int: charging_ev = self.data.set.charging_ev_data - if ((self.data.config.auto_phase_switch_hw is False and self.data.get.charge_state) or + if self.data.get.evse_signaling == EvseSignaling.HLC: + phases = self.data.get.phases_in_use + elif ((self.data.config.auto_phase_switch_hw is False and self.data.get.charge_state) or self.data.control_parameter.failed_phase_switches > self.MAX_FAILED_PHASE_SWITCHES): # Wenn keine Umschaltung verbaut ist, die Phasenzahl nehmen, mit der geladen wird. Damit werden zB auch # einphasige EV an dreiphasigen openWBs korrekt berücksichtigt. @@ -542,6 +549,16 @@ def get_max_phase_hw(self) -> int: log.debug(f"Anzahl angeschlossener Phasen beschränkt die nutzbaren Phasen auf {phases}") return phases + def hw_bidi_capable(self) -> BidiState: + if self.data.get.evse_signaling is None: + return BidiState.CP_NOT_BIDI_CAPABLE + elif self.data.get.evse_signaling != "HLC": + return BidiState.CP_WRONG_PROTOCOL + elif self.data.set.charging_ev_data.ev_template.data.bidi is False: + return BidiState.EV_NOT_BIDI_CAPABLE + else: + return BidiState.BIDI_CAPABLE + def set_phases(self, phases: int) -> int: charging_ev = self.data.set.charging_ev_data @@ -564,28 +581,47 @@ def set_phases(self, phases: int) -> int: self.data.control_parameter.phases = phases return phases - def check_min_max_current(self, required_current: float, phases: int, pv: bool = False) -> float: - required_current_prev = required_current - required_current, msg = self.data.set.charging_ev_data.check_min_max_current( - self.data.control_parameter, - required_current, - phases, - self.template.data.charging_type, - pv) + def check_cp_max_current(self, required_current: float, phases: int) -> float: + sign = 1 if required_current >= 0 else -1 + abs_current = abs(required_current) if self.template.data.charging_type == ChargingType.AC.value: if phases == 1: - required_current = min(required_current, self.template.data.max_current_single_phase) + abs_current = min(abs_current, self.template.data.max_current_single_phase) else: - required_current = min(required_current, self.template.data.max_current_multi_phases) + abs_current = min(abs_current, self.template.data.max_current_multi_phases) else: - required_current = min(required_current, self.template.data.dc_max_current) + abs_current = min(abs_current, self.template.data.dc_max_current) + return sign * abs_current + + def check_min_max_current(self, required_current: float, phases: int) -> float: + required_current_prev = required_current + msg = None + if self.data.control_parameter.submode == Chargemode.BIDI_CHARGING: + if required_current < 0: + if self.data.get.max_discharge_power / phases / 230 > required_current: + required_current = self.data.get.max_discharge_power / phases / 230 + msg = f"Die vom Auto übertragene Entladeleistung begrenzt den Strom auf " \ + f"maximal {round(required_current, 2)} A." + else: + if self.data.get.max_charge_power / phases / 230 < required_current: + required_current = self.data.get.max_charge_power / phases / 230 + msg = f"Die vom Auto übertragene Ladeleistung begrenzt den Strom auf " \ + f"maximal {round(required_current, 2)} A." + required_current = self.check_cp_max_current(required_current, phases) + else: + required_current, msg = self.data.set.charging_ev_data.check_min_max_current( + required_current, + phases, + self.template.data.charging_type) + required_current = self.check_cp_max_current(required_current, phases) if required_current != required_current_prev and msg is None: msg = ("Die Einstellungen in dem Ladepunkt-Profil beschränken den Strom auf " - f"maximal {required_current} A.") + f"maximal {round(required_current, 2)} A.") self.set_state_and_log(msg) return required_current def set_required_currents(self, required_current: float) -> None: + self.data.control_parameter.required_current = required_current control_parameter = self.data.control_parameter try: for i in range(0, control_parameter.phases): @@ -651,26 +687,26 @@ def update(self, ev_list: Dict[str, Ev]) -> None: if charging_possible: try: charging_ev = self._get_charging_ev(vehicle, ev_list) - max_phase_hw = self.get_max_phase_hw() state, message_ev, submode, required_current, phases = charging_ev.get_required_current( self.data.set.charge_template, self.data.control_parameter, - max_phase_hw, + self.get_max_phase_hw(), self.cp_ev_support_phase_switch(), self.template.data.charging_type, self.data.control_parameter.timestamp_chargemode_changed or create_timestamp(), - self.data.set.log.imported_since_plugged) + self.data.set.log.imported_since_plugged, + self.hw_bidi_capable(), + self.data.get.phases_in_use) phases = self.get_phases_by_selected_chargemode(phases) phases = self.set_phases(phases) self._pub_connected_vehicle(charging_ev) required_current = self.chargepoint_module.add_conversion_loss_to_current(required_current) - # Einhaltung des Minimal- und Maximalstroms prüfen - required_current = self.check_min_max_current( - required_current, self.data.control_parameter.phases) - required_current = self.chargepoint_module.add_conversion_loss_to_current(required_current) self.set_chargemode_changed(submode) self.set_submode_changed(submode) - self.set_control_parameter(submode, required_current) + self.set_control_parameter(submode) + # Einhaltung des Minimal- und Maximalstroms prüfen + required_current = self.check_min_max_current(required_current, self.data.control_parameter.phases) + required_current = self.chargepoint_module.add_conversion_loss_to_current(required_current) self.set_required_currents(required_current) self.check_phase_switch_completed() @@ -863,6 +899,7 @@ def cp_ev_chargemode_support_phase_switch(self) -> bool: def cp_ev_support_phase_switch(self) -> bool: return (self.data.config.auto_phase_switch_hw and + self.data.get.evse_signaling != EvseSignaling.HLC and self.data.set.charging_ev_data.ev_template.data.prevent_phase_switch is False) def chargemode_support_phase_switch(self) -> bool: diff --git a/packages/control/chargepoint/chargepoint_data.py b/packages/control/chargepoint/chargepoint_data.py index fda7661c88..4d433a8205 100644 --- a/packages/control/chargepoint/chargepoint_data.py +++ b/packages/control/chargepoint/chargepoint_data.py @@ -8,6 +8,7 @@ from control.ev.ev import Ev from dataclass_utils.factories import currents_list_factory, empty_dict_factory, voltages_list_factory from helpermodules.constants import NO_ERROR +from modules.chargepoints.openwb_pro.chargepoint_module import EvseSignaling from modules.common.abstract_chargepoint import AbstractChargepoint @@ -102,10 +103,14 @@ class Get: daily_exported: float = 0 error_timestamp: int = 0 evse_current: Optional[float] = None + # kann auch zur Laufzeit geändert werden + evse_signaling: Optional[EvseSignaling] = None exported: float = 0 fault_str: str = NO_ERROR fault_state: int = 0 imported: float = 0 + max_charge_power: Optional[float] = None + max_discharge_power: Optional[float] = None max_evse_current: Optional[int] = None phases_in_use: int = 0 plug_state: bool = False diff --git a/packages/control/counter.py b/packages/control/counter.py index 32af37e5c4..d82b106be1 100644 --- a/packages/control/counter.py +++ b/packages/control/counter.py @@ -7,6 +7,8 @@ from typing import List, Optional, Tuple from control import data +from control.algorithm.chargemodes import CONSIDERED_CHARGE_MODES_BIDI_DISCHARGE +from control.algorithm.filter_chargepoints import get_chargepoints_by_chargemodes from control.algorithm.utils import get_medium_charging_current from control.chargemode import Chargemode from control.ev.ev import Ev @@ -515,3 +517,17 @@ def limit_raw_power_left_to_surplus(surplus) -> None: counter.data.set.surplus_power_left = surplus log.debug(f'Zähler {counter.num}: Begrenzung der verbleibenden Leistung auf ' f'{counter.data.set.surplus_power_left}W') + + +def set_raw_surplus_power_left() -> None: + """ Bei surplus power left ist auch Leistung drin, die Autos zugeteilt bekommen, aber nicht ziehen und dann wird + ins Netz eingespeist. + beim Bidi-Laden den Regelmodus rausrechnen, da sonst zum Regelmodus und nicht zum Nullpunkt geregelt wird. + """ + grid_counter = data.data.counter_all_data.get_evu_counter() + bidi_power = 0 + chargepoint_by_chargemodes = get_chargepoints_by_chargemodes(CONSIDERED_CHARGE_MODES_BIDI_DISCHARGE) + for cp in chargepoint_by_chargemodes: + bidi_power += cp.data.get.power + grid_counter.data.set.surplus_power_left = grid_counter.data.get.power * -1 + bidi_power + log.debug(f"Nullpunktanpassung {grid_counter.data.set.surplus_power_left}W") diff --git a/packages/control/ev/charge_template.py b/packages/control/ev/charge_template.py index 308a0ad843..9d84697072 100644 --- a/packages/control/ev/charge_template.py +++ b/packages/control/ev/charge_template.py @@ -9,8 +9,9 @@ from control.chargepoint.charging_type import ChargingType from control.chargepoint.control_parameter import ControlParameter from control.ev.ev_template import EvTemplate +from control.text import BidiState from dataclass_utils.factories import empty_list_factory -from helpermodules.abstract_plans import Limit, TimeChargingPlan, limit_factory, ScheduledChargingPlan +from helpermodules.abstract_plans import Limit, limit_factory, ScheduledChargingPlan, TimeChargingPlan from helpermodules import timecheck log = logging.getLogger(__name__) @@ -38,6 +39,10 @@ class TimeCharging: "topic": ""}) # Dict[int, TimeChargingPlan] wird bei der dict to dataclass Konvertierung nicht unterstützt +def scheduled_charging_plan_factory() -> ScheduledChargingPlan: + return ScheduledChargingPlan() + + @dataclass class EcoCharging: current: int = 6 @@ -304,19 +309,20 @@ def eco_charging(self, log.exception("Fehler im ev-Modul "+str(self.data.id)) return 0, "stop", "Keine Ladung, da ein interner Fehler aufgetreten ist: "+traceback.format_exc(), 0 - def scheduled_charging_recent_plan(self, - soc: float, - ev_template: EvTemplate, - phases: int, - used_amount: float, - max_hw_phases: int, - phase_switch_supported: bool, - charging_type: str, - chargemode_switch_timestamp: float, - control_parameter: ControlParameter, - soc_request_interval_offset: int) -> Optional[SelectedPlan]: + def _find_recent_plan(self, + plans: List[ScheduledChargingPlan], + soc: float, + ev_template: EvTemplate, + used_amount: float, + max_hw_phases: int, + phase_switch_supported: bool, + charging_type: str, + chargemode_switch_timestamp: float, + control_parameter: ControlParameter, + soc_request_interval_offset: int, + hw_bidi: bool): plans_diff_end_date = [] - for p in self.data.chargemode.scheduled_charging.plans: + for p in plans: if p.active: if p.limit.selected == "soc" and soc is None: raise ValueError("Um Zielladen mit SoC-Ziel nutzen zu können, bitte ein SoC-Modul konfigurieren " @@ -346,13 +352,13 @@ def scheduled_charging_recent_plan(self, plan_id = list(plan_dict.keys())[0] plan_end_time = list(plan_dict.values())[0] - for p in self.data.chargemode.scheduled_charging.plans: + for p in plans: if p.id == plan_id: plan = p remaining_time, missing_amount, phases, duration = self._calc_remaining_time( plan, plan_end_time, soc, ev_template, used_amount, max_hw_phases, phase_switch_supported, - charging_type, control_parameter.phases, soc_request_interval_offset) + charging_type, control_parameter.phases, soc_request_interval_offset, hw_bidi) return SelectedPlan(remaining_time=remaining_time, duration=duration, @@ -362,6 +368,47 @@ def scheduled_charging_recent_plan(self, else: return None + def scheduled_charging(self, + soc: float, + ev_template: EvTemplate, + used_amount: float, + max_hw_phases: int, + phase_switch_supported: bool, + charging_type: str, + chargemode_switch_timestamp: float, + control_parameter: ControlParameter, + soc_request_interval_offset: int, + bidi_state: BidiState) -> Optional[SelectedPlan]: + if bidi_state == BidiState.BIDI_CAPABLE and soc is None: + raise Exception("Für den Lademodis Bidi ist zwingend ein SoC-Modul erforderlich. Soll der " + "SoC ausschließlich aus dem Fahrzeug ausgelesen werden, bitte auf " + "manuellen SoC mit Auslesung aus dem Fahrzeug umstellen.") + plan_data = self._find_recent_plan(self.data.chargemode.scheduled_charging.plans, + soc, + ev_template, + used_amount, + max_hw_phases, + phase_switch_supported, + charging_type, + chargemode_switch_timestamp, + control_parameter, + soc_request_interval_offset, + bidi_state) + if plan_data: + control_parameter.current_plan = plan_data.plan.id + else: + control_parameter.current_plan = None + return self.scheduled_charging_calc_current( + plan_data, + soc, + used_amount, + control_parameter.phases, + control_parameter.min_current, + soc_request_interval_offset, + charging_type, + ev_template, + bidi_state) + def _calc_remaining_time(self, plan: ScheduledChargingPlan, plan_end_time: float, @@ -372,25 +419,36 @@ def _calc_remaining_time(self, phase_switch_supported: bool, charging_type: str, control_parameter_phases: int, - soc_request_interval_offset: int) -> SelectedPlan: - if plan.phases_to_use == 0: + soc_request_interval_offset: int, + bidi_state: BidiState) -> SelectedPlan: + bidi = BidiState.BIDI_CAPABLE and plan.bidi + if bidi: + duration, missing_amount = self._calculate_duration( + plan, soc, ev_template.data.battery_capacity, + used_amount, control_parameter_phases, charging_type, ev_template, bidi) + remaining_time = plan_end_time - duration + phases = control_parameter_phases + elif plan.phases_to_use == 0: if max_hw_phases == 1: duration, missing_amount = self._calculate_duration( - plan, soc, ev_template.data.battery_capacity, used_amount, 1, charging_type, ev_template) + plan, soc, ev_template.data.battery_capacity, + used_amount, 1, charging_type, ev_template, bidi) remaining_time = plan_end_time - duration phases = 1 elif phase_switch_supported is False: duration, missing_amount = self._calculate_duration( plan, soc, ev_template.data.battery_capacity, used_amount, control_parameter_phases, - charging_type, ev_template) + charging_type, ev_template, bidi) phases = control_parameter_phases remaining_time = plan_end_time - duration else: duration_3p, missing_amount = self._calculate_duration( - plan, soc, ev_template.data.battery_capacity, used_amount, 3, charging_type, ev_template) + plan, soc, ev_template.data.battery_capacity, used_amount, 3, + charging_type, ev_template, bidi) remaining_time_3p = plan_end_time - duration_3p duration_1p, missing_amount = self._calculate_duration( - plan, soc, ev_template.data.battery_capacity, used_amount, 1, charging_type, ev_template) + plan, soc, ev_template.data.battery_capacity, used_amount, 1, + charging_type, ev_template, bidi) remaining_time_1p = plan_end_time - duration_1p # Kurz vor dem nächsten Abfragen des SoC, wenn noch der alte SoC da ist, kann es sein, dass die Zeit # für 1p nicht mehr reicht, weil die Regelung den neuen SoC noch nicht kennt. @@ -407,7 +465,7 @@ def _calc_remaining_time(self, elif plan.phases_to_use == 3 or plan.phases_to_use == 1: duration, missing_amount = self._calculate_duration( plan, soc, ev_template.data.battery_capacity, - used_amount, plan.phases_to_use, charging_type, ev_template) + used_amount, plan.phases_to_use, charging_type, ev_template, bidi) remaining_time = plan_end_time - duration phases = plan.phases_to_use @@ -421,7 +479,8 @@ def _calculate_duration(self, used_amount: float, phases: int, charging_type: str, - ev_template: EvTemplate) -> Tuple[float, float]: + ev_template: EvTemplate, + bidi: bool) -> Tuple[float, float]: if plan.limit.selected == "soc": if soc is not None: @@ -430,32 +489,37 @@ def _calculate_duration(self, raise ValueError("Um Zielladen mit SoC-Ziel nutzen zu können, bitte ein SoC-Modul konfigurieren.") else: missing_amount = plan.limit.amount - used_amount - current = plan.current if charging_type == ChargingType.AC.value else plan.dc_current - current = max(current, ev_template.data.min_current if charging_type == - ChargingType.AC.value else ev_template.data.dc_min_current) - duration = missing_amount/(current * phases*230) * 3600 + if bidi: + duration = missing_amount/plan.bidi_power * 3600 + else: + current = plan.current if charging_type == ChargingType.AC.value else plan.dc_current + current = max(current, ev_template.data.min_current if charging_type == + ChargingType.AC.value else ev_template.data.dc_min_current) + duration = missing_amount/(current * phases*230) * 3600 return duration, missing_amount SCHEDULED_REACHED_LIMIT_SOC = ("Kein Zielladen, da noch Zeit bis zum Zieltermin ist. " "Kein Zielladen mit Überschuss, da das SoC-Limit für Überschuss-Laden " + - "erreicht wurde.") + "erreicht wurde. ") SCHEDULED_CHARGING_REACHED_LIMIT_SOC = ("Kein Zielladen, da das Limit für Fahrzeug Laden mit Überschuss (SoC-Limit)" - " sowie der Fahrzeug-SoC (Ziel-SoC) bereits erreicht wurde.") - SCHEDULED_CHARGING_REACHED_AMOUNT = "Kein Zielladen, da die Energiemenge bereits erreicht wurde." + " sowie der Fahrzeug-SoC (Ziel-SoC) bereits erreicht wurde. ") + SCHEDULED_CHARGING_REACHED_AMOUNT = "Kein Zielladen, da die Energiemenge bereits erreicht wurde. " SCHEDULED_CHARGING_REACHED_SCHEDULED_SOC = ("Falls vorhanden wird mit EVU-Überschuss geladen, da der Ziel-Soc " - "für Zielladen bereits erreicht wurde.") + "für Zielladen bereits erreicht wurde. ") + SCHEDULED_CHARGING_BIDI = ("Der Ziel-Soc für Zielladen wurde bereits erreicht. Das Auto wird " + "bidirektional ge-/entladen, sodass möglichst weder Bezug noch " + "Einspeisung erfolgt. ") SCHEDULED_CHARGING_NO_PLANS_CONFIGURED = "Keine Ladung, da keine Ziel-Termine konfiguriert sind." - SCHEDULED_CHARGING_NO_DATE_PENDING = "Kein Zielladen, da kein Ziel-Termin ansteht." - SCHEDULED_CHARGING_USE_PV = ("Laden startet {}. Falls vorhanden, " - "wird mit Überschuss geladen.") - SCHEDULED_CHARGING_MAX_CURRENT = "Zielladen mit {}A. Der Ladestrom wurde erhöht, um das Ziel zu erreichen." + SCHEDULED_CHARGING_NO_DATE_PENDING = "Kein Zielladen, da kein Ziel-Termin ansteht. " + SCHEDULED_CHARGING_USE_PV = "Laden startet {}. Falls vorhanden, wird mit Überschuss geladen. " + SCHEDULED_CHARGING_MAX_CURRENT = "Zielladen mit {}A. Der Ladestrom wurde erhöht, um das Ziel zu erreichen. " SCHEDULED_CHARGING_LIMITED_BY_SOC = 'einen SoC von {}%' SCHEDULED_CHARGING_LIMITED_BY_AMOUNT = '{}kWh geladene Energie' SCHEDULED_CHARGING_IN_TIME = ('Zielladen mit mindestens {}A, um {} um {} zu erreichen. Falls vorhanden wird ' - 'zusätzlich EVU-Überschuss geladen.') + 'zusätzlich EVU-Überschuss geladen. ') SCHEDULED_CHARGING_CHEAP_HOUR = "Zielladen, da ein günstiger Zeitpunkt zum preisbasierten Laden ist. {}" SCHEDULED_CHARGING_EXPENSIVE_HOUR = ("Zielladen ausstehend, da jetzt kein günstiger Zeitpunkt zum preisbasierten " - "Laden ist. {} Falls vorhanden, wird mit Überschuss geladen.") + "Laden ist. {} Falls vorhanden, wird mit Überschuss geladen. ") def scheduled_charging_calc_current(self, selected_plan: Optional[SelectedPlan], @@ -465,7 +529,8 @@ def scheduled_charging_calc_current(self, min_current: int, soc_request_interval_offset: int, charging_type: str, - ev_template: EvTemplate) -> Tuple[float, str, str, int]: + ev_template: EvTemplate, + bidi_state: BidiState) -> Tuple[float, str, str, int]: current = 0 submode = "stop" if selected_plan is None: @@ -488,15 +553,25 @@ def scheduled_charging_calc_current(self, if limit.selected == "soc" and soc >= limit.soc_limit and soc >= limit.soc_scheduled: message = self.SCHEDULED_CHARGING_REACHED_LIMIT_SOC elif limit.selected == "soc" and limit.soc_scheduled <= soc < limit.soc_limit: - message = self.SCHEDULED_CHARGING_REACHED_SCHEDULED_SOC - current = min_current - submode = "pv_charging" - # bei Überschuss-Laden mit der Phasenzahl aus den control_parameter laden, - # um die Umschaltung zu berücksichtigen. - phases = plan.phases_to_use_pv + if plan.bidi and bidi_state == BidiState.BIDI_CAPABLE: + message = self.SCHEDULED_CHARGING_BIDI + current = min_current + submode = "bidi_charging" + phases = control_parameter_phases + else: + message = self.SCHEDULED_CHARGING_REACHED_SCHEDULED_SOC + if plan.bidi and bidi_state != BidiState.BIDI_CAPABLE: + message += bidi_state.value + current = min_current + submode = "pv_charging" + # bei Überschuss-Laden mit der Phasenzahl aus den control_parameter laden, + # um die Umschaltung zu berücksichtigen. + phases = plan.phases_to_use_pv elif limit.selected == "amount" and used_amount >= limit.amount: message = self.SCHEDULED_CHARGING_REACHED_AMOUNT elif 0 - soc_request_interval_offset < selected_plan.remaining_time < 300 + soc_request_interval_offset: + # Wenn der SoC ein paar Minuten alt ist, kann der Termin trotzdem gehalten werden. + # Zielladen kann nicht genauer arbeiten, als das Abfrageintervall vom SoC. # 5 Min vor spätestem Ladestart if limit.selected == "soc": limit_string = self.SCHEDULED_CHARGING_LIMITED_BY_SOC.format(limit.soc_scheduled) @@ -557,3 +632,9 @@ def scheduled_charging_calc_current(self, def stop(self) -> Tuple[int, str, str]: return 0, "stop", "Keine Ladung, da der Lademodus Stop aktiv ist." + + def bidi_charging_allowed(self, selected_plan: int, soc: float): + # Wenn zu über den Limit-SoC geladen wurde, darf nur noch bidirektional entladen werden. + for plan in self.data.chargemode.scheduled_charging.plans: + if plan.id == selected_plan: + return soc <= plan.limit.soc_limit diff --git a/packages/control/ev/charge_template_test.py b/packages/control/ev/charge_template_test.py index 8512597652..a3a2998682 100644 --- a/packages/control/ev/charge_template_test.py +++ b/packages/control/ev/charge_template_test.py @@ -12,6 +12,7 @@ from control.ev.charge_template import ChargeTemplate from control.ev.ev_template import EvTemplate, EvTemplateData from control.general import General +from control.text import BidiState from helpermodules import timecheck from helpermodules.abstract_plans import Limit, ScheduledChargingPlan, TimeChargingPlan @@ -110,7 +111,11 @@ def test_instant_charging(selected: str, current_soc: float, used_amount: float, id="min current configured"), pytest.param(15, 0, None, 15, 900, (6, "pv_charging", None, 0), id="bare pv charging"), ]) -def test_pv_charging(min_soc: int, min_current: int, limit_selected: str, current_soc: float, used_amount: float, +def test_pv_charging(min_soc: int, + min_current: int, + limit_selected: str, + current_soc: float, + used_amount: float, expected: Tuple[int, str, Optional[str], int]): # setup ct = ChargeTemplate() @@ -153,7 +158,7 @@ def test_calc_remaining_time(phases_to_use, # execution remaining_time, missing_amount, phases, duration = ct._calc_remaining_time( - plan, 6000, 50, evt, 3000, max_hw_phases, phase_switch_supported, ChargingType.AC.value, 2, 0) + plan, 6000, 50, evt, 3000, max_hw_phases, phase_switch_supported, ChargingType.AC.value, 2, 0, False) # end time 16.5.22 10:00 # evaluation @@ -161,18 +166,24 @@ def test_calc_remaining_time(phases_to_use, @pytest.mark.parametrize( - "selected, phases, expected_duration, expected_missing_amount", + "selected, phases, bidi, expected_duration, expected_missing_amount", [ - pytest.param("soc", 1, 10062.111801242236, 9000, id="soc, one phase"), - pytest.param("amount", 2, 447.2049689440994, 800, id="amount, two phases"), + pytest.param("soc", 1, False, 10062.111801242236, 9000, id="soc, one phase"), + pytest.param("amount", 2, False, 447.2049689440994, 800, id="amount, two phases"), + pytest.param("soc", 2, True, 3240.0, 9000, id="bidi"), ]) -def test_calculate_duration(selected: str, phases: int, expected_duration: float, expected_missing_amount: float): +def test_calculate_duration(selected: str, + phases: int, + bidi: bool, + expected_duration: float, + expected_missing_amount: float): # setup ct = ChargeTemplate() - plan = ScheduledChargingPlan() + plan = ScheduledChargingPlan(bidi=bidi) plan.limit.selected = selected # execution - duration, missing_amount = ct._calculate_duration(plan, 60, 45000, 200, phases, ChargingType.AC.value, EvTemplate()) + duration, missing_amount = ct._calculate_duration( + plan, 60, 45000, 200, phases, ChargingType.AC.value, EvTemplate(), bidi) # evaluation assert duration == expected_duration @@ -196,15 +207,16 @@ def test_scheduled_charging_recent_plan(end_time_mock, monkeypatch.setattr(ChargeTemplate, "_calc_remaining_time", calculate_duration_mock) check_end_time_mock = Mock(side_effect=end_time_mock) monkeypatch.setattr(timecheck, "check_end_time", check_end_time_mock) + control_parameter = ControlParameter() ct = ChargeTemplate() plan_mock_0 = Mock(spec=ScheduledChargingPlan, active=True, current=14, id=0, limit=Limit(selected="amount")) plan_mock_1 = Mock(spec=ScheduledChargingPlan, active=True, current=14, id=1, limit=Limit(selected="amount")) plan_mock_2 = Mock(spec=ScheduledChargingPlan, active=True, current=14, id=2, limit=Limit(selected="amount")) - ct.data.chargemode.scheduled_charging.plans = [plan_mock_0, plan_mock_1, plan_mock_2] + plans = [plan_mock_0, plan_mock_1, plan_mock_2] # execution - selected_plan = ct.scheduled_charging_recent_plan( - 60, EvTemplate(), 3, 200, 3, True, ChargingType.AC.value, 1652688000, Mock(spec=ControlParameter), 0) + selected_plan = ct._find_recent_plan( + plans, 60, EvTemplate(), 200, 3, True, ChargingType.AC.value, 1652688000, control_parameter, 0, False) # evaluation if selected_plan: @@ -214,52 +226,57 @@ def test_scheduled_charging_recent_plan(end_time_mock, @pytest.mark.parametrize( - "plan_data, soc, used_amount, selected, expected", + "plan_data, soc, used_amount, selected, bidi, expected", [ - pytest.param(None, 0, 0, "none", (0, "stop", + pytest.param(None, 0, 0, "none", False, (0, "stop", ChargeTemplate.SCHEDULED_CHARGING_NO_DATE_PENDING, 3), id="no date pending"), - pytest.param(SelectedPlan(duration=3600), 90, 0, "soc", (0, "stop", + pytest.param(SelectedPlan(duration=3600), 90, 0, "soc", False, (0, "stop", ChargeTemplate.SCHEDULED_CHARGING_REACHED_LIMIT_SOC, 1), id="reached limit soc"), - pytest.param(SelectedPlan(duration=3600), 80, 0, "soc", (6, "pv_charging", + pytest.param(SelectedPlan(duration=3600), 80, 0, "soc", False, (6, "pv_charging", ChargeTemplate.SCHEDULED_CHARGING_REACHED_SCHEDULED_SOC, 0), id="reached scheduled soc"), - pytest.param(SelectedPlan(phases=3, duration=3600), 0, 1000, "amount", (0, "stop", + pytest.param(SelectedPlan(duration=3600), 80, 0, "soc", True, (6, "bidi_charging", + ChargeTemplate.SCHEDULED_CHARGING_BIDI, 3), id="reached scheduled soc, bidi"), + pytest.param(SelectedPlan(phases=3, duration=3600), 0, 1000, "amount", False, (0, "stop", ChargeTemplate.SCHEDULED_CHARGING_REACHED_AMOUNT, 3), id="reached amount"), pytest.param(SelectedPlan(remaining_time=299, duration=3600), 0, 999, "amount", - (14, "instant_charging", ChargeTemplate.SCHEDULED_CHARGING_IN_TIME.format( + False, (14, "instant_charging", ChargeTemplate.SCHEDULED_CHARGING_IN_TIME.format( 14, ChargeTemplate.SCHEDULED_CHARGING_LIMITED_BY_AMOUNT.format(1.0), "07:00"), 1), id="in time, limited by amount"), pytest.param(SelectedPlan(remaining_time=299, duration=3600), 79, 0, "soc", - (14, "instant_charging", ChargeTemplate.SCHEDULED_CHARGING_IN_TIME.format( + False, (14, "instant_charging", ChargeTemplate.SCHEDULED_CHARGING_IN_TIME.format( 14, ChargeTemplate.SCHEDULED_CHARGING_LIMITED_BY_SOC.format(80), "07:00"), 1), id="in time, limited by soc"), pytest.param(SelectedPlan(remaining_time=-500, duration=3600, missing_amount=9000, phases=3), 79, 0, "soc", - (15.147265077138847, "instant_charging", + False, (15.147265077138847, "instant_charging", ChargeTemplate.SCHEDULED_CHARGING_MAX_CURRENT.format(15.15), 3), id="too late, but didn't miss for today"), pytest.param(SelectedPlan(remaining_time=-800, duration=780, missing_amount=4600, phases=3), 79, 0, "soc", - (16, "instant_charging", + False, (16, "instant_charging", ChargeTemplate.SCHEDULED_CHARGING_MAX_CURRENT.format(16), 3), id="few minutes too late, but didn't miss for today"), pytest.param(SelectedPlan(remaining_time=301, duration=3600), 79, 0, "soc", - (6, "pv_charging", ChargeTemplate.SCHEDULED_CHARGING_USE_PV.format("um 8:45 Uhr"), 0), + False, (6, "pv_charging", ChargeTemplate.SCHEDULED_CHARGING_USE_PV.format("um 8:45 Uhr"), 0), id="too early, use pv"), ]) def test_scheduled_charging_calc_current(plan_data: SelectedPlan, soc: int, used_amount: float, selected: str, + bidi: bool, expected: Tuple[float, str, str, int]): # setup ct = ChargeTemplate() plan = ScheduledChargingPlan(active=True, id=0) plan.limit.selected = selected + plan.bidi = bidi # json verwandelt Keys in strings ct.data.chargemode.scheduled_charging.plans = [plan] if plan_data: plan_data.plan = plan # execution - ret = ct.scheduled_charging_calc_current(plan_data, soc, used_amount, 3, 6, 0, ChargingType.AC.value, EvTemplate()) + ret = ct.scheduled_charging_calc_current(plan_data, soc, used_amount, 3, 6, + 0, ChargingType.AC.value, EvTemplate(), BidiState.BIDI_CAPABLE) # evaluation assert ret == expected @@ -270,7 +287,8 @@ def test_scheduled_charging_calc_current_no_plans(): ct = ChargeTemplate() # execution - ret = ct.scheduled_charging_calc_current(None, 63, 5, 3, 6, 0, ChargingType.AC.value, EvTemplate()) + ret = ct.scheduled_charging_calc_current( + None, 63, 5, 3, 6, 0, ChargingType.AC.value, EvTemplate(), BidiState.BIDI_CAPABLE) # evaluation assert ret == (0, "stop", ChargeTemplate.SCHEDULED_CHARGING_NO_PLANS_CONFIGURED, 3) @@ -300,7 +318,8 @@ def test_scheduled_charging_calc_current_electricity_tariff(loading_hour, expect # execution ret = ct.scheduled_charging_calc_current(SelectedPlan( - plan=plan, remaining_time=301, phases=3, duration=3600), 79, 0, 3, 6, 0, ChargingType.AC.value, EvTemplate()) + plan=plan, remaining_time=301, phases=3, duration=3600), + 79, 0, 3, 6, 0, ChargingType.AC.value, EvTemplate(), BidiState.BIDI_CAPABLE) # evaluation assert ret == expected diff --git a/packages/control/ev/ev.py b/packages/control/ev/ev.py index 832921ee29..989179b51c 100644 --- a/packages/control/ev/ev.py +++ b/packages/control/ev/ev.py @@ -6,6 +6,13 @@ stärke wird auch geprüft, ob sich an diesen Parametern etwas geändert hat. Falls ja, muss das EV in der Regelung neu priorisiert werden und eine neue Zuteilung des Stroms erhalten. """ +from modules.common.configurable_vehicle import ConfigurableVehicle +from modules.common.abstract_vehicle import VehicleUpdateData +from helpermodules.constants import NO_ERROR +from helpermodules import timecheck +from dataclass_utils.factories import empty_list_factory +from control.text import BidiState +from control.limiting_value import LimitingValue, LoadmanagementLimit from dataclasses import dataclass, field import logging from typing import List, Optional, Tuple @@ -18,12 +25,6 @@ from control.chargepoint.charging_type import ChargingType from control.chargepoint.control_parameter import ControlParameter from control.ev.ev_template import EvTemplate -from control.limiting_value import LimitingValue, LoadmanagementLimit -from dataclass_utils.factories import empty_list_factory -from helpermodules import timecheck -from helpermodules.constants import NO_ERROR -from modules.common.abstract_vehicle import VehicleUpdateData -from modules.common.configurable_vehicle import ConfigurableVehicle log = logging.getLogger(__name__) @@ -120,7 +121,9 @@ def get_required_current(self, phase_switch_supported: bool, charging_type: str, chargemode_switch_timestamp: float, - imported_since_plugged: float) -> Tuple[bool, Optional[str], str, float, int]: + imported_since_plugged: float, + bidi: BidiState, + phases_in_use: int) -> Tuple[bool, Optional[str], str, float, int]: """ ermittelt, ob und mit welchem Strom das EV geladen werden soll (unabhängig vom Lastmanagement) Parameter @@ -156,30 +159,17 @@ def get_required_current(self, else: soc_request_interval_offset = 0 if charge_template.data.chargemode.selected == "scheduled_charging": - plan_data = charge_template.scheduled_charging_recent_plan( + required_current, submode, tmp_message, phases = charge_template.scheduled_charging( self.data.get.soc, self.ev_template, - control_parameter.phases, imported_since_plugged, max_phases_hw, phase_switch_supported, charging_type, chargemode_switch_timestamp, control_parameter, - soc_request_interval_offset) - if plan_data: - control_parameter.current_plan = plan_data.plan.id - else: - control_parameter.current_plan = None - required_current, submode, tmp_message, phases = charge_template.scheduled_charging_calc_current( - plan_data, - self.data.get.soc, - imported_since_plugged, - control_parameter.phases, - control_parameter.min_current, soc_request_interval_offset, - charging_type, - self.ev_template) + bidi) message = f"{tmp_message or ''}".strip() # Wenn Zielladen auf Überschuss wartet, prüfen, ob Zeitladen aktiv ist. @@ -224,11 +214,9 @@ def get_required_current(self, control_parameter.phases) def check_min_max_current(self, - control_parameter: ControlParameter, required_current: float, phases: int, - charging_type: str, - pv: bool = False,) -> Tuple[float, Optional[str]]: + charging_type: str) -> Tuple[float, Optional[str]]: """ prüft, ob der gesetzte Ladestrom über dem Mindest-Ladestrom und unter dem Maximal-Ladestrom des EVs liegt. Falls nicht, wird der Ladestrom auf den Mindest-Ladestrom bzw. den Maximal-Ladestrom des EV gesetzt. Wenn PV-Laden aktiv ist, darf die Stromstärke nicht unter den PV-Mindeststrom gesetzt werden. @@ -239,13 +227,10 @@ def check_min_max_current(self, if phases != 0: # EV soll/darf nicht laden if required_current != 0: - if not pv: - if charging_type == ChargingType.AC.value: - min_current = self.ev_template.data.min_current - else: - min_current = self.ev_template.data.dc_min_current + if charging_type == ChargingType.AC.value: + min_current = self.ev_template.data.min_current else: - min_current = control_parameter.required_current + min_current = self.ev_template.data.dc_min_current if required_current < min_current: required_current = min_current msg = ("Die Einstellungen in dem Fahrzeug-Profil beschränken den Strom auf " diff --git a/packages/control/ev/ev_template.py b/packages/control/ev/ev_template.py index e2db7ba6c5..0fb4e53fa1 100644 --- a/packages/control/ev/ev_template.py +++ b/packages/control/ev/ev_template.py @@ -21,6 +21,7 @@ class EvTemplateData: efficiency: float = 90 nominal_difference: float = 1 keep_charge_active_duration: int = 40 + bidi: bool = False def ev_template_data_factory() -> EvTemplateData: diff --git a/packages/control/text.py b/packages/control/text.py new file mode 100644 index 0000000000..290893882f --- /dev/null +++ b/packages/control/text.py @@ -0,0 +1,8 @@ +from enum import Enum + + +class BidiState(Enum): + BIDI_CAPABLE = "" + CP_NOT_BIDI_CAPABLE = "Bidirektionales Laden ist nur mit einer openWB Pro oder Pro+ möglich. " + CP_WRONG_PROTOCOL = "Bitte in den Einstellungen der openWB Pro/Pro+ die Charging Version auf 'HLC' stellen. " + EV_NOT_BIDI_CAPABLE = "Das Fahrzeug unterstützt kein bidirektionales Laden. " diff --git a/packages/helpermodules/abstract_plans.py b/packages/helpermodules/abstract_plans.py index a2dbc4a4fb..f56763ae03 100644 --- a/packages/helpermodules/abstract_plans.py +++ b/packages/helpermodules/abstract_plans.py @@ -77,6 +77,8 @@ class TimeframePlan(PlanBase): @dataclass class ScheduledChargingPlan(PlanBase): + bidi: bool = False + bidi_power: int = 10000 current: int = 14 dc_current: float = 145 et_active: bool = False diff --git a/packages/helpermodules/setdata.py b/packages/helpermodules/setdata.py index 1f7c5f9ef2..10dc200d4e 100644 --- a/packages/helpermodules/setdata.py +++ b/packages/helpermodules/setdata.py @@ -436,7 +436,7 @@ def process_chargepoint_topic(self, msg: mqtt.MQTTMessage): "openWB/set/chargepoint/get/power" in msg.topic or "openWB/set/chargepoint/get/daily_imported" in msg.topic or "openWB/set/chargepoint/get/daily_exported" in msg.topic): - self._validate_value(msg, float, [(0, float("inf"))]) + self._validate_value(msg, float) elif re.search("chargepoint/[0-9]+/config/template$", msg.topic) is not None: self._validate_value(msg, int, pub_json=True) elif "template" in msg.topic: @@ -451,9 +451,9 @@ def process_chargepoint_topic(self, msg: mqtt.MQTTMessage): elif ("/set/current" in msg.topic or "/set/current_prev" in msg.topic): if hardware_configuration.get_hardware_configuration_setting("dc_charging"): - self._validate_value(msg, float, [(0, 0), (6, 32), (0, 450)]) + self._validate_value(msg, float, [(float("-inf"), 0), (0, 0), (6, 32), (0, 450)]) else: - self._validate_value(msg, float, [(6, 32), (0, 0)]) + self._validate_value(msg, float, [(float("-inf"), 0), (6, 32), (0, 0)]) elif ("/set/energy_to_charge" in msg.topic or "/set/required_power" in msg.topic): self._validate_value(msg, float, [(0, float("inf"))]) @@ -528,14 +528,18 @@ def process_chargepoint_get_topics(self, msg): self._validate_value(msg, float, [(40, 60)]) elif ("/get/daily_imported" in msg.topic or "/get/daily_exported" in msg.topic or - "/get/power" in msg.topic or "/get/charging_current" in msg.topic or "/get/charging_power" in msg.topic or "/get/charging_voltage" in msg.topic or + "/get/max_charge_power" in msg.topic or "/get/imported" in msg.topic or "/get/exported" in msg.topic or "/get/soc_timestamp" in msg.topic): self._validate_value(msg, float, [(0, float("inf"))]) + elif "/get/max_discharge_power" in msg.topic: + self._validate_value(msg, float, [(float("-inf"), 0)]) + elif "/get/power" in msg.topic: + self._validate_value(msg, float) elif "/get/phases_in_use" in msg.topic: self._validate_value(msg, int, [(0, 3)]) elif ("/get/charge_state" in msg.topic or @@ -545,7 +549,7 @@ def process_chargepoint_get_topics(self, msg): self._validate_value(msg, int, [(0, 2)]) elif ("/get/evse_current" in msg.topic or "/get/max_evse_current" in msg.topic): - self._validate_value(msg, float, [(0, 0), (6, 32), (600, 3200)]) + self._validate_value(msg, float, [(float("-inf"), 0), (0, 0), (6, 32), (600, 3200)]) elif ("/get/version" in msg.topic or "/get/current_branch" in msg.topic or "/get/current_commit" in msg.topic): @@ -558,7 +562,8 @@ def process_chargepoint_get_topics(self, msg): "/get/heartbeat" in msg.topic or "/get/rfid" in msg.topic or "/get/vehicle_id" in msg.topic or - "/get/serial_number" in msg.topic): + "/get/serial_number" in msg.topic or + "/get/evse_signaling" in msg.topic): self._validate_value(msg, str) elif ("/get/soc" in msg.topic): self._validate_value(msg, float, [(0, 100)]) diff --git a/packages/helpermodules/update_config.py b/packages/helpermodules/update_config.py index 0fe58e6b5d..7d07a43281 100644 --- a/packages/helpermodules/update_config.py +++ b/packages/helpermodules/update_config.py @@ -57,7 +57,7 @@ class UpdateConfig: - DATASTORE_VERSION = 91 + DATASTORE_VERSION = 92 valid_topic = [ "^openWB/bat/config/bat_control_permitted$", @@ -2396,3 +2396,19 @@ def upgrade(topic: str, payload) -> None: time.sleep(2) self._loop_all_received_topics(upgrade) self.__update_topic("openWB/system/datastore_version", 91) + + def upgrade_datastore_91(self) -> None: + def upgrade(topic: str, payload) -> Optional[dict]: + if re.search("openWB/vehicle/template/ev_template/[0-9]+$", topic) is not None: + payload = decode_payload(payload) + if "bidi" not in payload: + payload.update({"bidi": False}) + return {topic: payload} + elif re.search("openWB/vehicle/template/charge_template/[0-9]+$", topic) is not None: + payload = decode_payload(payload) + for plan in payload["chargemode"]["scheduled_charging"]["plans"]: + if "bidi" not in plan: + plan.update({"bidi": False, "bidi_power": 10000}) + return {topic: payload} + self._loop_all_received_topics(upgrade) + self.__update_topic("openWB/system/datastore_version", 92) diff --git a/packages/modules/chargepoints/mqtt/chargepoint_module.py b/packages/modules/chargepoints/mqtt/chargepoint_module.py index d837b9c698..8bbd7f0deb 100644 --- a/packages/modules/chargepoints/mqtt/chargepoint_module.py +++ b/packages/modules/chargepoints/mqtt/chargepoint_module.py @@ -85,7 +85,10 @@ def on_message(client, userdata, message): max_evse_current=parse_received_topics("max_evse_current"), version=parse_received_topics("version"), current_branch=parse_received_topics("current_branch"), - current_commit=parse_received_topics("current_commit") + current_commit=parse_received_topics("current_commit"), + max_charge_power=parse_received_topics("max_charge_power"), + max_discharge_power=parse_received_topics("max_discharge_power"), + evse_signaling=parse_received_topics("evse_signaling"), ) self.store.set(chargepoint_state) except KeyError: diff --git a/packages/modules/chargepoints/openwb_pro/chargepoint_module.py b/packages/modules/chargepoints/openwb_pro/chargepoint_module.py index 5aac5315e7..9a7d0767d2 100644 --- a/packages/modules/chargepoints/openwb_pro/chargepoint_module.py +++ b/packages/modules/chargepoints/openwb_pro/chargepoint_module.py @@ -17,6 +17,13 @@ log = logging.getLogger(__name__) +class EvseSignaling: + HLC = "HLC" + ISO15118 = "ISO15118" + FAKE_HIGHLEVEL = "fake_highlevel" + PWM = "PWM" + + class ChargepointModule(AbstractChargepoint): WRONG_CHARGE_STATE = "Lade-Status ist nicht aktiv, aber Strom fließt." WRONG_PLUG_STATE = "Ladepunkt ist nicht angesteckt, aber es wird geladen." @@ -77,7 +84,8 @@ def request_values(self) -> ChargepointState: phases_in_use=json_rsp["phases_in_use"], vehicle_id=json_rsp["vehicle_id"], evse_current=json_rsp["offered_current"], - serial_number=json_rsp["serial"] + serial_number=json_rsp["serial"], + evse_signaling=json_rsp["evse_signaling"], ) if json_rsp.get("voltages"): @@ -97,6 +105,10 @@ def request_values(self) -> ChargepointState: chargepoint_state.rfid = json_rsp["rfid_tag"] if json_rsp.get("rfid_timestamp"): chargepoint_state.rfid_timestamp = json_rsp["rfid_timestamp"] + if json_rsp.get("max_discharge_power"): + chargepoint_state.max_discharge_power = json_rsp["max_discharge_power"] + if json_rsp.get("max_charge_power"): + chargepoint_state.max_charge_power = json_rsp["max_charge_power"] self.validate_values(chargepoint_state) self.client_error_context.reset_error_counter() diff --git a/packages/modules/chargepoints/openwb_pro/chargepoint_module_test.py b/packages/modules/chargepoints/openwb_pro/chargepoint_module_test.py index f4ca737c38..78c64b73ea 100644 --- a/packages/modules/chargepoints/openwb_pro/chargepoint_module_test.py +++ b/packages/modules/chargepoints/openwb_pro/chargepoint_module_test.py @@ -25,6 +25,7 @@ def sample_chargepoint_state(): rfid_timestamp=1700839714, vehicle_id="98:ED:5C:B4:EE:8D", evse_current=6, + evse_signaling="fake highlevel + basic iec61851", serial_number="823950" ) @@ -65,6 +66,7 @@ def sample_chargepoint_extended(): rfid=None, frequency=50.2, evse_current=6, + evse_signaling="unclear\n", serial_number="493826" ) diff --git a/packages/modules/chargepoints/openwb_pro/config.py b/packages/modules/chargepoints/openwb_pro/config.py index cab0724e61..e534ee193e 100644 --- a/packages/modules/chargepoints/openwb_pro/config.py +++ b/packages/modules/chargepoints/openwb_pro/config.py @@ -4,7 +4,9 @@ class OpenWBProConfiguration: - def __init__(self, ip_address: Optional[str] = None, duo_num: int = 0): + def __init__(self, + ip_address: Optional[str] = None, + duo_num: int = 0) -> None: self.ip_address = ip_address self.duo_num = duo_num diff --git a/packages/modules/common/component_state.py b/packages/modules/common/component_state.py index 969e1cbdd3..ee856d4e22 100644 --- a/packages/modules/common/component_state.py +++ b/packages/modules/common/component_state.py @@ -173,6 +173,9 @@ def __init__(self, charging_current: Optional[float] = 0, charging_voltage: Optional[float] = 0, charging_power: Optional[float] = 0, + evse_signaling: Optional[str] = None, + max_charge_power: Optional[float] = None, + max_discharge_power: Optional[float] = None, powers: Optional[List[Optional[float]]] = None, voltages: Optional[List[Optional[float]]] = None, power_factors: Optional[List[Optional[float]]] = None, @@ -212,6 +215,9 @@ def __init__(self, self.current_branch = current_branch self.current_commit = current_commit self.version = version + self.evse_signaling = evse_signaling + self.max_charge_power = max_charge_power + self.max_discharge_power = max_discharge_power @auto_str diff --git a/packages/modules/common/store/_chargepoint.py b/packages/modules/common/store/_chargepoint.py index 0aa92624e7..8743646f41 100644 --- a/packages/modules/common/store/_chargepoint.py +++ b/packages/modules/common/store/_chargepoint.py @@ -56,9 +56,13 @@ def update(self): pub_to_broker("openWB/set/chargepoint/" + str(self.num) + "/get/evse_current", self.state.evse_current) pub_to_broker("openWB/set/chargepoint/" + str(self.num) + "/get/vehicle_id", self.state.vehicle_id) pub_to_broker("openWB/set/chargepoint/" + str(self.num) + "/get/max_evse_current", self.state.max_evse_current) + pub_to_broker("openWB/set/chargepoint/" + str(self.num) + "/get/max_charge_power", self.state.max_charge_power) + pub_to_broker("openWB/set/chargepoint/" + str(self.num) + "/get/max_discharge_power", + self.state.max_discharge_power) pub_to_broker("openWB/set/chargepoint/" + str(self.num) + "/get/version", self.state.version) pub_to_broker("openWB/set/chargepoint/" + str(self.num) + "/get/current_branch", self.state.current_branch) pub_to_broker("openWB/set/chargepoint/" + str(self.num) + "/get/current_commit", self.state.current_commit) + pub_to_broker("openWB/set/chargepoint/" + str(self.num) + "/get/evse_signaling", self.state.evse_signaling) def get_chargepoint_value_store(id: int) -> ValueStore[ChargepointState]: