diff --git a/.gitignore b/.gitignore index b44b30afe8..c2d4c15890 100644 --- a/.gitignore +++ b/.gitignore @@ -16,11 +16,14 @@ data/config/eebus/certs/* data/log/* data/charge_log/* data/daily_log/* +data/daily_totals/* data/monthly_log/* +data/monthly_totals/* data/backup/*.tar.gz data/backup/*.openwb-backup* data/restore/*.tar.gz data/data_migration/*.tar.gz +data/generate_totals.lock ramdisk/* web/lastcommit !.gitignore diff --git a/data/config/mosquitto/public/default-dynamic-security.json b/data/config/mosquitto/public/default-dynamic-security.json index e20de3a77d..8e0cbba91a 100644 --- a/data/config/mosquitto/public/default-dynamic-security.json +++ b/data/config/mosquitto/public/default-dynamic-security.json @@ -81,7 +81,7 @@ "anonymousGroup": "anonymous", "roles": [ { - "rolename": "openwb-version:9", + "rolename": "openwb-version:10", "textname": "openWB Versionsnummer", "textdescription": "Diese Rolle ist ein Platzhalter für die openWB Versionsnummer und wird automatisch aktualisiert. Sie hat keine direkten Berechtigungen.", "acls": [] @@ -194,6 +194,12 @@ "priority": 0, "allow": true }, + { + "acltype": "publishClientReceive", + "topic": "openWB/system/log_data_ready", + "priority": 0, + "allow": true + }, { "acltype": "publishClientReceive", "topic": "openWB/system/usage_terms_acknowledged", @@ -500,6 +506,12 @@ "topic": "openWB/system/security/access/Status", "priority": 0, "allow": true + }, + { + "acltype": "publishClientReceive", + "topic": "openWB/counter/get/loadmanagement_prios", + "priority": 0, + "allow": true } ] }, @@ -1686,6 +1698,18 @@ "topic": "openWB/chargepoint/+/config", "priority": 0, "allow": true + }, + { + "acltype": "publishClientReceive", + "topic": "openWB/consumer/+/module", + "priority": 0, + "allow": true + }, + { + "acltype": "publishClientReceive", + "topic": "openWB/consumer/+/extra_meter", + "priority": 0, + "allow": true } ] }, @@ -1799,19 +1823,73 @@ "acls": [ { "acltype": "publishClientSend", - "topic": "openWB/consumer/+/config", + "topic": "openWB/set/command/+/todo/addConsumer", "priority": 0, "allow": true }, { "acltype": "publishClientSend", - "topic": "openWB/consumer/+/module", + "topic": "openWB/set/command/+/todo/removeConsumer", "priority": 0, "allow": true }, { "acltype": "publishClientSend", - "topic": "openWB/consumer/+/usage", + "topic": "openWB/set/command/+/todo/addConsumerSchedulePlan", + "priority": 0, + "allow": true + }, + { + "acltype": "publishClientSend", + "topic": "openWB/set/command/+/todo/removeConsumerSchedulePlan", + "priority": 0, + "allow": true + }, + { + "acltype": "publishClientSend", + "topic": "openWB/set/command/+/todo/addConsumerTimePlan", + "priority": 0, + "allow": true + }, + { + "acltype": "publishClientSend", + "topic": "openWB/set/command/+/todo/removeConsumerTimePlan", + "priority": 0, + "allow": true + }, + { + "acltype": "publishClientSend", + "topic": "openWB/set/consumer/+/config", + "priority": 0, + "allow": true + }, + { + "acltype": "publishClientSend", + "topic": "openWB/set/consumer/+/module", + "priority": 0, + "allow": true + }, + { + "acltype": "publishClientSend", + "topic": "openWB/set/consumer/+/usage", + "priority": 0, + "allow": true + }, + { + "acltype": "publishClientSend", + "topic": "openWB/set/consumer/+/extra_meter", + "priority": 0, + "allow": true + }, + { + "acltype": "publishClientSend", + "topic": "openWB/set/general/legacy_smarthome_active", + "priority": 0, + "allow": true + }, + { + "acltype": "publishClientReceive", + "topic": "openWB/system/security/access/ConsumerConfiguration", "priority": 0, "allow": true }, @@ -1833,6 +1911,42 @@ "priority": 0, "allow": true }, + { + "acltype": "publishClientReceive", + "topic": "openWB/consumer/+/extra_meter", + "priority": 0, + "allow": true + }, + { + "acltype": "publishClientReceive", + "topic": "openWB/general/extern", + "priority": 0, + "allow": true + }, + { + "acltype": "publishClientReceive", + "topic": "openWB/general/legacy_smarthome_active", + "priority": 0, + "allow": true + }, + { + "acltype": "publishClientReceive", + "topic": "openWB/counter/get/hierarchy", + "priority": 0, + "allow": true + }, + { + "acltype": "publishClientReceive", + "topic": "openWB/system/device/+/config", + "priority": 0, + "allow": true + }, + { + "acltype": "publishClientReceive", + "topic": "openWB/system/device/+/component/+/config", + "priority": 0, + "allow": true + }, { "acltype": "publishClientReceive", "topic": "openWB/system/configurable/consumers", diff --git a/packages/control/algorithm/bidi_charging.py b/packages/control/algorithm/bidi_charging.py index 40aea05879..434c3e9fc4 100644 --- a/packages/control/algorithm/bidi_charging.py +++ b/packages/control/algorithm/bidi_charging.py @@ -1,10 +1,17 @@ import logging from control import data +from control.chargemode import Chargemode from control.algorithm.chargemodes import CONSIDERED_CHARGE_MODES_BIDI_DISCHARGE from control.algorithm.filter_chargepoints import filtered_loads_to_str, get_loads_by_chargemodes from control.chargepoint.chargepoint import Chargepoint from helpermodules.phase_handling import voltages_mean +from control.limiting_value import LoadmanagementLimit +from control.loadmanagement import Loadmanagement + +import control.algorithm.common as common +from typing import List + log = logging.getLogger(__name__) @@ -13,33 +20,126 @@ def __init__(self): pass def set_bidi(self): + """Setzt den verfügbaren Strom für bidirektionales Laden und Entladen. + Dafür gibt es jeweils eine Grenze für das Laden und Entladen + (raw_currents_left und raw_exported_currents_left). + Entladen erhöht die verfügbare Grenze für das Laden, während Laden + die verfügbare Grenze für das Entladen erhöht. + Das Lastmanagement stellt sicher, dass die Summe der Ströme die am + Zähler verfügbare Leistung nicht überschreitet. + """ 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 + + # CPs nach Modus filtern, dann verfügbare Ströme gegen alle relevanten Counter-Limits begrenzen. + # -> Um bei mehreren Bidi-CPs das Entladen bei der Nullpunktanpassung gleichmäßig zu verteilen preferenced_cps = get_loads_by_chargemodes(CONSIDERED_CHARGE_MODES_BIDI_DISCHARGE) if preferenced_cps: log.info(f"Verbraucher {filtered_loads_to_str(preferenced_cps)}") while len(preferenced_cps): cp: Chargepoint = 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)] + + counts = self.get_counts(cp) + + cp.data.set.target_current = 0 + + missing_currents = self.get_missing_currents(preferenced_cps, grid_counter) + log.debug(f"Bidi-LP{cp.num}: missing currents {missing_currents}A") + + counters = data.data.counter_all_data.get_counters_to_check(cp.num) + for counter in counters: + available_currents, limit = Loadmanagement().get_available_currents_bidi( + missing_currents, voltages_mean(cp.data.get.voltages), data.data.counter_data[counter]) + + if limit.limiting_value is not None: + cp.data.control_parameter.limit = limit + + available_for_cp = common.available_current_for_load( + cp, counts, available_currents, missing_currents, bidi_mode=True) + + # Der neue Strom darf nicht höher als der bisher gesetzte Strom sein + current = common.get_current_to_set( + cp.data.set.current, available_for_cp, cp.data.set.target_current) + + cp.data.set.current = current + log.info(f"LP{cp.num}: Stromstärke {current}A") + + # Ausgabe LIMIT-MSG + self._set_loadmangement_message(current, limit, cp) + + common.set_current_counterdiff(cp.data.set.target_current, current, cp, surplus=True) + + preferenced_cps.pop(0) + + def _set_loadmangement_message(self, + current: float, + limit: LoadmanagementLimit, + chargepoint: Chargepoint) -> None: + # Strom muss an diesem Zähler geändert werden + log.debug( + f"current {current} target {chargepoint.data.set.target_current} set current {chargepoint.data.set.current}" + f" required currents {chargepoint.data.control_parameter.required_currents}") + required_currents = chargepoint.data.control_parameter.required_currents + required_current = min(required_currents) if current < 0 else max(required_currents) + if (limit.message and + # Strom erreicht nicht die vorgegebene Stromstärke + round(current, 2) != round(required_current, 2)): + if current == 0: + chargepoint.set_state_and_log(f"Es kann nicht mit der vorgegebenen Stromstärke geladen/entladen werden" + f"{limit.message}") + elif current < 0: + chargepoint.set_state_and_log(f"Es kann nicht mit der vorgegebenen Stromstärke entladen werden" + f"{limit.message}") + else: + chargepoint.set_state_and_log(f"Es kann nicht mit der vorgegebenen Stromstärke geladen werden" + f"{limit.message}") + + def get_counts(self, chargepoint: Chargepoint) -> List[int]: + + counts = [0]*3 + required_currents = chargepoint.data.control_parameter.required_currents + for i in range(3): + if required_currents[i] != 0: + counts[i] += 1 + return counts + + def get_missing_currents(self, preferenced_cps: List[Chargepoint], grid_counter) -> List[float]: + cp = preferenced_cps[0] + missing_currents = [0, 0, 0] + if cp.data.control_parameter.chargemode == Chargemode.INSTANT_CHARGING: + # Entladen im Instant-Charging-Modus + + # cp.control_parameter.required_currents enthält duch self.surplus_controlled.set_required_current_to_max() + # in calc_current nur noch die maximalen Ströme + dc_current = cp.data.set.charging_ev_data.charge_template.data.chargemode.instant_charging.dc_current + if dc_current < 0: + # Phasen in use berücksichtigen + missing_currents = [dc_current 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) + + else: + # Default Nullpunktanpassung + 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] = cp.check_min_max_current(missing_currents[index], - cp.data.get.phases_in_use) - grid_counter.update_surplus_values_left(missing_currents, voltages_mean(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) + 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) + + return missing_currents diff --git a/packages/control/algorithm/chargemodes.py b/packages/control/algorithm/chargemodes.py index 5dbbd23673..7f085a4452 100644 --- a/packages/control/algorithm/chargemodes.py +++ b/packages/control/algorithm/chargemodes.py @@ -2,22 +2,23 @@ # Lademodi in absteigender Priorität # Tupel-Inhalt:(eingestellter Modus, tatsächlich genutzter Modus, Priorität) -CHARGEMODES = ((Chargemode.SCHEDULED_CHARGING, Chargemode.INSTANT_CHARGING), - (None, Chargemode.TIME_CHARGING), - (Chargemode.INSTANT_CHARGING, Chargemode.INSTANT_CHARGING), - (Chargemode.ECO_CHARGING, Chargemode.INSTANT_CHARGING), - (Chargemode.PV_CHARGING, Chargemode.INSTANT_CHARGING), - (Chargemode.SCHEDULED_CHARGING, Chargemode.PV_CHARGING), - (Chargemode.ECO_CHARGING, Chargemode.PV_CHARGING), - (Chargemode.PV_CHARGING, Chargemode.PV_CHARGING), +CHARGEMODES = ((Chargemode.SCHEDULED_CHARGING, Chargemode.INSTANT_CHARGING), # 0 + (None, Chargemode.TIME_CHARGING), # 1 + (Chargemode.INSTANT_CHARGING, Chargemode.INSTANT_CHARGING), # 2 + (Chargemode.ECO_CHARGING, Chargemode.INSTANT_CHARGING), # 3 + (Chargemode.PV_CHARGING, Chargemode.INSTANT_CHARGING), # 4 + (Chargemode.SCHEDULED_CHARGING, Chargemode.PV_CHARGING), # 5 + (Chargemode.ECO_CHARGING, Chargemode.PV_CHARGING), # 6 + (Chargemode.PV_CHARGING, Chargemode.PV_CHARGING), # 7 # niedrigere Priorität soll nachrangig geladen, aber zuerst entladen werden - (Chargemode.SCHEDULED_CHARGING, Chargemode.BIDI_CHARGING), - (None, Chargemode.STOP),) + (Chargemode.INSTANT_CHARGING, Chargemode.BIDI_CHARGING), # 8 + (Chargemode.SCHEDULED_CHARGING, Chargemode.BIDI_CHARGING), # 9 + (None, Chargemode.STOP),) # 10 CONSIDERED_CHARGE_MODES_SURPLUS = (CHARGEMODES[0], *CHARGEMODES[3:8]) CONSIDERED_CHARGE_MODES_PV_ONLY = CHARGEMODES[5:8] CONSIDERED_CHARGE_MODES_ADDITIONAL_CURRENT = CHARGEMODES[0:5] -CONSIDERED_CHARGE_MODES_MIN_CURRENT = CHARGEMODES[0:-2] -CONSIDERED_CHARGE_MODES_NO_CURRENT = (CHARGEMODES[9],) -CONSIDERED_CHARGE_MODES_BIDI_DISCHARGE = (CHARGEMODES[8],) +CONSIDERED_CHARGE_MODES_MIN_CURRENT = CHARGEMODES[0:-3] +CONSIDERED_CHARGE_MODES_NO_CURRENT = (CHARGEMODES[10],) +CONSIDERED_CHARGE_MODES_BIDI_DISCHARGE = (CHARGEMODES[8:10]) CONSIDERED_CHARGE_MODES_CHARGING = CHARGEMODES[0:8] diff --git a/packages/control/algorithm/common.py b/packages/control/algorithm/common.py index 9027c97259..5cd1fa1c8a 100644 --- a/packages/control/algorithm/common.py +++ b/packages/control/algorithm/common.py @@ -68,10 +68,9 @@ def set_current_counterdiff(diff_current: float, required_currents = load.data.control_parameter.required_currents considered_current = consider_less_charging_chargepoint_in_loadmanagement( load, current) - # gar nicht ladende Autos? - diff = max(considered_current - diff_current, 0) + diff = considered_current - diff_current diffs = [diff if required_currents[i] != 0 else 0 for i in range(3)] - if max(diffs) > 0: + if any(diff_value != 0 for diff_value in diffs): counters = data.data.counter_all_data.get_counters_to_check(load.num) for counter in counters: if surplus: @@ -98,10 +97,16 @@ def get_current_to_set(set_current: float, diff: float, prev_current: float) -> wird.""" new_current = prev_current + diff if set_current is not None: - if new_current > set_current: - log.debug("Neuer Soll-Strom darf nicht höher als bisher gesetzter sein: " - f"bisher {set_current}A, neuer {new_current}") - return set_current + if diff < 0: + if new_current < set_current: + log.debug("Neuer Soll-Strom darf beim Entladen nicht niedriger als bisher gesetzter sein: " + f"bisher {set_current}A, neuer {new_current}") + return set_current + else: + if new_current > set_current: + log.debug("Neuer Soll-Strom darf nicht höher als bisher gesetzter sein: " + f"bisher {set_current}A, neuer {new_current}") + return set_current return new_current # tested @@ -110,11 +115,29 @@ def get_current_to_set(set_current: float, diff: float, prev_current: float) -> def available_current_for_load(load: Load, counts: List[int], available_currents: List[float], - missing_currents: List[float]) -> float: + missing_currents: List[float], + bidi_mode: bool = False) -> float: control_parameter = load.data.control_parameter available_current = float("inf") missing_current_cp = control_parameter.required_current - load.data.set.target_current + if bidi_mode: + is_discharge = missing_current_cp < 0 + available_current = float("-inf") if is_discharge else float("inf") + for i in range(0, 3): + if control_parameter.required_currents[i] == 0 or counts[i] == 0: + continue + phase_available_current = available_currents[i] / counts[i] + if is_discharge: + available_current = max( + max(missing_current_cp, phase_available_current), available_current) + else: + available_current = min( + min(missing_current_cp, phase_available_current), available_current) + if available_current in [float("inf"), float("-inf")]: + available_current = missing_current_cp + return available_current + for i in range(0, 3): shared_with = counts[i] if (control_parameter.required_currents[i] != 0 and missing_currents[i] != available_currents[i]): diff --git a/packages/control/algorithm/integration_test/bidi_charging_test.py b/packages/control/algorithm/integration_test/bidi_charging_test.py index 0a065ac734..1c1906b0af 100644 --- a/packages/control/algorithm/integration_test/bidi_charging_test.py +++ b/packages/control/algorithm/integration_test/bidi_charging_test.py @@ -2,6 +2,7 @@ from unittest.mock import Mock from control import data +from control import loadmanagement from control.algorithm.algorithm import Algorithm from control.chargemode import Chargemode @@ -9,6 +10,11 @@ @pytest.fixture() def bidi_cps(): def _setup(*cps): + for counter in ("counter0", "counter6"): + data.data.counter_data[counter].data.set.raw_currents_left = [32]*3 + data.data.counter_data[counter].data.set.raw_exported_currents_left = [32]*3 + data.data.counter_data[counter].data.set.raw_power_left = 22000 + data.data.counter_data[counter].data.set.raw_exported_power_left = 22000 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 @@ -30,7 +36,9 @@ def test_cp3_bidi(grid_power: float, expected_current: float, bidi_cps, all_cp_n # setup bidi_cps("cp3") data.data.counter_data["counter0"].data.get.power = grid_power - return_mock = Mock(reurn_value=True) + return_mock = Mock(return_value=True) + mock_get_component_name_by_id = Mock(return_value="Garage") + monkeypatch.setattr(loadmanagement, "get_component_name_by_id", mock_get_component_name_by_id) monkeypatch.setattr( data.data.cp_data["cp3"].data.set.charging_ev_data.charge_template, "bidi_charging_allowed", return_mock) @@ -48,6 +56,8 @@ 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 + mock_get_component_name_by_id = Mock(return_value="Garage") + monkeypatch.setattr(loadmanagement, "get_component_name_by_id", mock_get_component_name_by_id) # execution Algorithm().calc_current() @@ -57,3 +67,139 @@ def test_cp3_cp4_bidi_discharge(bidi_cps, all_cp_not_charging, monkeypatch): 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 + + +def test_cp3_bidi_instant_discharge_uses_dc_current(bidi_cps, all_cp_not_charging, monkeypatch): + # Testet, ob das Entladen entsprechend vom max_discharge_power limitiert wird + + # setup + bidi_cps("cp3") + control_parameter = data.data.cp_data["cp3"].data.control_parameter + control_parameter.chargemode = Chargemode.INSTANT_CHARGING + data.data.cp_data["cp3"].data.set.charging_ev_data.data.get.soc = 80 + data.data.cp_data["cp3"].data.set.charging_ev_data.charge_template.data.chargemode.instant_charging.dc_current = -20 + mock_get_component_name_by_id = Mock(return_value="Garage") + monkeypatch.setattr(loadmanagement, "get_component_name_by_id", mock_get_component_name_by_id) + + # execution + Algorithm().calc_current() + + # evaluation + assert data.data.cp_data["cp3"].data.set.current == -15.942028985507246 + 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 == 10310.0 + + +def test_cp3_bidi_instant_discharge_limited_by_counter_export_current( + bidi_cps, all_cp_not_charging, monkeypatch): + # Testet, ob das Entladen entsprechend vom Counter limitiert wird + + # setup + bidi_cps("cp3") + control_parameter = data.data.cp_data["cp3"].data.control_parameter + control_parameter.chargemode = Chargemode.INSTANT_CHARGING + data.data.cp_data["cp3"].data.get.max_discharge_power = -22000 + data.data.cp_data["cp3"].data.set.charging_ev_data.data.get.soc = 80 + data.data.cp_data["cp3"].data.set.charging_ev_data.charge_template.data.chargemode.instant_charging.dc_current = -20 + for counter in ("counter0", "counter6"): + data.data.counter_data[counter].data.set.raw_exported_currents_left = [10]*3 + mock_get_component_name_by_id = Mock(return_value="Garage") + monkeypatch.setattr(loadmanagement, "get_component_name_by_id", mock_get_component_name_by_id) + + # execution + Algorithm().calc_current() + + # evaluation + assert data.data.cp_data["cp3"].data.set.current == -10 + assert data.data.cp_data["cp4"].data.set.current == 0 + assert data.data.cp_data["cp5"].data.set.current == 0 + + +@pytest.mark.parametrize( + "max_discharge_power, expected_cp4_current, expected_cp5_current", + [ + # Begrenzt durch max_discharge_power und Counter + pytest.param(-11000, -15.942028985507246, -4.057971014492754, id="max_discharge_11kW"), + # Nur durch Counter begrenzt + pytest.param(-110000, -20, 0, id="max_discharge_110kW"), + ], +) +def test_cp3_cp4_bidi_instant_discharge_splits_limited_export_current( + max_discharge_power, expected_cp4_current, expected_cp5_current, + bidi_cps, all_cp_not_charging, monkeypatch): + # Beide CPs sind auf einer Ebene und wollen -20 A entladen + # Counter erlaubt insgesamt nur -20 A pro Phase + + # setup + bidi_cps("cp4", "cp5") + for cp in ("cp4", "cp5"): + control_parameter = data.data.cp_data[cp].data.control_parameter + control_parameter.chargemode = Chargemode.INSTANT_CHARGING + data.data.cp_data[cp].data.get.max_discharge_power = max_discharge_power + data.data.cp_data[cp].data.set.charging_ev_data.data.get.soc = 80 + (data.data.cp_data[cp].data.set.charging_ev_data.charge_template.data + .chargemode.instant_charging.dc_current) = -20 + + for counter in ("counter0", "counter6"): + data.data.counter_data[counter].data.set.raw_exported_currents_left = [20]*3 + mock_get_component_name_by_id = Mock(return_value="Garage") + monkeypatch.setattr(loadmanagement, "get_component_name_by_id", mock_get_component_name_by_id) + + # execution + Algorithm().calc_current() + + # evaluation + assert data.data.cp_data["cp3"].data.set.current == 0 + assert data.data.cp_data["cp4"].data.set.current == expected_cp4_current + assert data.data.cp_data["cp5"].data.set.current == expected_cp5_current + + +def test_cp4_bidi_discharge_unlocks_cp5_instant_charging_in_next_cycle( + bidi_cps, all_cp_not_charging, monkeypatch): + # Zyklus 1: CP4 entlaedt, CP5 bleibt bei 20 A (Counter-Limit 20 A). + # Zyklus 2: Entladung ist am Counter reflektiert, dadurch kann CP5 auf 40 A steigen. + + # setup + bidi_cps("cp4") + cp4_control_parameter = data.data.cp_data["cp4"].data.control_parameter + cp4_control_parameter.chargemode = Chargemode.INSTANT_CHARGING + data.data.cp_data["cp4"].data.get.max_discharge_power = -22000 + data.data.cp_data["cp4"].data.set.charging_ev_data.data.get.soc = 80 + data.data.cp_data["cp4"].data.set.charging_ev_data.charge_template.data.chargemode.instant_charging.dc_current = -20 + + cp5 = data.data.cp_data["cp5"] + cp5_control_parameter = cp5.data.control_parameter + cp5_control_parameter.chargemode = Chargemode.INSTANT_CHARGING + cp5_control_parameter.submode = Chargemode.INSTANT_CHARGING + cp5_control_parameter.phases = 3 + cp5_control_parameter.required_currents = [50]*3 + cp5_control_parameter.required_current = 50 + cp5.data.get.max_charge_power = 110000 + cp5.data.get.charge_state = True + cp5.data.get.currents = [20]*3 + cp5.data.set.charging_ev_data.ev_template.data.max_current_multi_phases = 50 + cp5.template.data.max_current_multi_phases = 50 + + for counter in ("counter0", "counter6"): + data.data.counter_data[counter].data.set.raw_currents_left = [20]*3 + data.data.counter_data[counter].data.set.raw_exported_currents_left = [20]*3 + data.data.counter_data[counter].data.set.raw_power_left = 100000 + data.data.counter_data[counter].data.set.raw_exported_power_left = 100000 + + mock_get_component_name_by_id = Mock(return_value="Garage") + monkeypatch.setattr(loadmanagement, "get_component_name_by_id", mock_get_component_name_by_id) + + # execution + evaluation cycle 1 + Algorithm().calc_current() + assert data.data.cp_data["cp4"].data.set.current == -20 + assert data.data.cp_data["cp5"].data.set.current == 20 + + # Entladung aus Zyklus 1 ist im naechsten Zyklus als zusaetzlicher Spielraum verfuegbar. + for counter in ("counter0", "counter6"): + data.data.counter_data[counter].data.set.raw_currents_left = [40]*3 + + # execution + evaluation cycle 2 + Algorithm().calc_current() + assert data.data.cp_data["cp4"].data.set.current == -20 + assert data.data.cp_data["cp5"].data.set.current == 40 diff --git a/packages/control/bat_all.py b/packages/control/bat_all.py index c1939c2187..af3cab264e 100644 --- a/packages/control/bat_all.py +++ b/packages/control/bat_all.py @@ -318,6 +318,31 @@ def setup_bat(self): except Exception: log.exception("Fehler im Bat-Modul") + def _charging_power_left_while_recovering(self, config) -> float: + """ Speicher-Vorrang, waehrend sich der Speicher auf dem Weg zum naechsten SoC-Ziel befindet + (unterhalb Mindest-SoC, oder oberhalb Mindest-SoC aber noch nicht wieder beim Maximal-SoC + angekommen, hysteresis_discharge=False): ermittelt, wie viel Leistung den Fahrzeugen dabei + verbleibt, unter Beruecksichtigung einer optional reservierten Ladeleistung. + """ + if self.data.get.power < 0: + # Wenn der Speicher entladen wird, darf diese Leistung nicht zum Laden der Fahrzeuge + # genutzt werden. Wenn der Speicher schneller regelt als die LP, würde sonst der Speicher + # reduziert werden. + charging_power_left = self.data.get.power + self.data.set.regulate_up = True + else: + if config.power_reserve_active: + # 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.power_reserve + if charging_power_left < 0: + self.data.set.regulate_up = True + else: + # Speicher wird geladen + charging_power_left = 0 + self.data.set.regulate_up = True + return charging_power_left + def get_charging_power_left_diff(self): """Ermittelt die Differenz zur aktuellen Batterie-Leistung, die zum Laden der EV verwendet werden darf. @@ -349,30 +374,18 @@ def get_charging_power_left_diff(self): # Speicher soll geladen werden um min SoC zu erreichen if self.data.get.soc < config.min_soc: self.data.set.hysteresis_discharge = False - if self.data.get.power < 0: - # Wenn der Speicher entladen wird, darf diese Leistung nicht zum Laden der Fahrzeuge - # genutzt werden. Wenn der Speicher schneller regelt als die LP, würde sonst der Speicher - # reduziert werden. - charging_power_left = self.data.get.power - self.data.set.regulate_up = True - else: - # Speicher-Vorrang bis zum Min-Soc - if config.power_reserve_active: - # 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.power_reserve - if charging_power_left < 0: - self.data.set.regulate_up = True - else: - # Speicher wird geladen - charging_power_left = 0 - self.data.set.regulate_up = True + charging_power_left = self._charging_power_left_while_recovering(config) # Speicher zwischen min und max SoC elif int(self.data.get.soc) >= config.min_soc and int(self.data.get.soc) < config.max_soc: # Speicher soll aktiv weder ge- noch entladen werden. # Mindest-SoC wird gehalten oder der Speicher mit weiterem vorhanden Überschuss geladen. if self.data.set.hysteresis_discharge is False: - charging_power_left = self.data.get.power + # Speicher befindet sich noch in der Erholung nach Unterschreiten des Mindest-SoC: + # Vorrang fuer den Speicher bis zum Erreichen des Maximal-SoC (hysteresis_discharge + # wird erst dort wieder True), analog zum Zweig unterhalb des Mindest-SoC - sonst + # bleibt der Speicher dauerhaft knapp oberhalb des Mindest-SoC haengen, weil die + # Hysterese nie zurueckgesetzt wird. + charging_power_left = self._charging_power_left_while_recovering(config) # Speicher darf wegen Hysterese bis min_soc entladen werden. else: if self.data.set.power_limit is None: @@ -404,8 +417,10 @@ def get_charging_power_left_diff(self): absolute_bat_discharge_power) log.debug(f"Erlaubte Entlade-Leistung nutzen {charging_power_left}W") else: - # Speicher sollte weder ge- noch entladen werden. - charging_power_left = base_power + # Entladung nicht erlaubt: die eigene Speicherleistung darf nicht ans Fahrzeug + # weitergereicht werden, sonst wird der Speicher entladen, obwohl der Nutzer das + # nicht erlaubt hat. + charging_power_left = 0 else: log.debug("Keine erlaubte Entladeleistung freigeben, da der Speicher mit einer vorgegeben " "Leistung entladen wird.") @@ -441,8 +456,10 @@ def get_charging_power_left_diff(self): absolute_bat_discharge_power) log.debug(f"Erlaubte Entlade-Leistung nutzen {charging_power_left}W") else: - # Speicher sollte weder ge- noch entladen werden. - charging_power_left = base_power + # Entladung nicht erlaubt: die eigene Speicherleistung darf nicht ans Fahrzeug + # weitergereicht werden, sonst wird der Speicher entladen, obwohl der Nutzer das + # nicht erlaubt hat. + charging_power_left = 0 else: log.debug("Keine erlaubte Entladeleistung freigeben, da der Speicher mit einer vorgegeben " "Leistung entladen wird.") diff --git a/packages/control/bat_all_test.py b/packages/control/bat_all_test.py index 4f0a6f7cbe..b877c058ac 100644 --- a/packages/control/bat_all_test.py +++ b/packages/control/bat_all_test.py @@ -128,10 +128,10 @@ class Params: Params("Mindest-SoC, SoC nicht erreicht, Speicher-Reserve ausgenutzt, Speicher lädt", ChargemodeConfigBat(mode="min_soc_bat_mode", power_reserve=2000, power_reserve_active=True), 2200, 40, 200, False), - Params("Mindest-SoC, SoC erreicht, Speicher entlädt", ChargemodeConfigBat(mode="min_soc_bat_mode"), -500, 90, -500, - False), - Params("Mindest-SoC, SoC erreicht, Speicher lädt", - ChargemodeConfigBat(mode="min_soc_bat_mode"), 500, 90, 500, False), + Params("Mindest-SoC, SoC erreicht, Entladung nicht erlaubt, Speicher entlädt trotzdem", + ChargemodeConfigBat(mode="min_soc_bat_mode"), -500, 90, 0, False), + Params("Mindest-SoC, SoC erreicht, Entladung nicht erlaubt, Speicher lädt", + ChargemodeConfigBat(mode="min_soc_bat_mode"), 500, 90, 0, False), Params("Mindest-SoC, SoC erreicht, Speicher ist voll", ChargemodeConfigBat(mode="min_soc_bat_mode"), 0, 100, 0, False), Params("Mindest-SoC, SoC erreicht, Entladung in Auto, Speicher entlädt, Entladeleistung nicht erreicht", @@ -156,14 +156,23 @@ class Params: "Speicher-Sperre aktiv"), ChargemodeConfigBat(mode="min_soc_bat_mode", power_discharge=500, power_discharge_active=True), 400, 90, 0, False, 600), - Params(("Mindest-SoC, Hysterese, EV-Vorrang, keine Speichernutzung"), - ChargemodeConfigBat(mode="min_soc_bat_mode"), 400, 60, 400, False, hysteresis_discharge=False), + Params(("Mindest-SoC, Hysterese, Erholung nach Unterschreiten, Speicher-Vorrang bis Maximal-SoC"), + ChargemodeConfigBat(mode="min_soc_bat_mode"), 400, 60, -100, True, hysteresis_discharge=False), + Params(("Mindest-SoC, Hysterese, Erholung nach Unterschreiten, Speicher-Reserve aktiv"), + ChargemodeConfigBat(mode="min_soc_bat_mode", power_reserve=2000, power_reserve_active=True), + 2200, 60, 200, False, hysteresis_discharge=False), + Params(("Mindest-SoC, Hysterese, Erholung nach Unterschreiten, Speicher entlädt noch"), + ChargemodeConfigBat(mode="min_soc_bat_mode"), -300, 60, -400, True, hysteresis_discharge=False), Params(("Mindest-SoC, Hysterese, Speicherentladung, Speichernutzung erlaubt"), ChargemodeConfigBat(mode="min_soc_bat_mode", power_discharge=500, power_discharge_active=True), 400, 60, 900, False, hysteresis_discharge=True), Params(("Mindest-SoC, Hysterese, Speicherentladung, Speichernutzung erlaubt, Speicher-Sperre aktiv"), ChargemodeConfigBat(mode="min_soc_bat_mode", power_discharge=500, power_discharge_active=True), 400, 60, 0, False, 600, hysteresis_discharge=True), + Params(("Mindest-SoC, Hysterese, Entladung nicht erlaubt, Speicher entlädt trotzdem"), + ChargemodeConfigBat(mode="min_soc_bat_mode"), -500, 60, 0, False, hysteresis_discharge=True), + Params(("Mindest-SoC, Hysterese, Entladung nicht erlaubt, Speicher lädt"), + ChargemodeConfigBat(mode="min_soc_bat_mode"), 500, 60, 0, False, hysteresis_discharge=True), ] diff --git a/packages/control/chargelog/chargelog.py b/packages/control/chargelog/chargelog.py index e9a87b7339..b8194e5eab 100644 --- a/packages/control/chargelog/chargelog.py +++ b/packages/control/chargelog/chargelog.py @@ -11,10 +11,10 @@ from typing import Any, Dict, List, Optional, Tuple from control import data -from helpermodules.measurement_logging.process_log import ( +from helpermodules.measurement_logging.process_log_calculation import ( FILE_ERRORS, CalculationType, _analyse_energy_source, _process_entries, get_totals) -from helpermodules.measurement_logging.write_log import LogType, LegacySmartHomeLogData, create_entry +from helpermodules.measurement_logging.process_log_entry_builder import LegacySmartHomeLogData, create_entry # alte Daten: Startzeitpunkt der Ladung, Endzeitpunkt, Geladene Reichweite, Energie, Leistung, Ladedauer, LP-Nummer, # Lademodus, ID-Tag @@ -423,7 +423,7 @@ def _get_reference_entries(cp, create_log_entry: bool = False) -> Tuple[List[Dic entries_day_before = get_daily_log(date_day_before)["entries"] previous_entry = entries_day_before[-1] - current_entry = create_entry(LogType.DAILY, LegacySmartHomeLogData(), previous_entry) + current_entry = create_entry(LegacySmartHomeLogData(), previous_entry) reference_entries = [previous_entry, current_entry] elif len(entries) >= 2: reference_entries = [entries[-2], entries[-1]] diff --git a/packages/control/chargepoint/chargepoint.py b/packages/control/chargepoint/chargepoint.py index db95b7666c..a26b402769 100644 --- a/packages/control/chargepoint/chargepoint.py +++ b/packages/control/chargepoint/chargepoint.py @@ -558,6 +558,10 @@ def check_min_max_current(self, required_current: float, phases: int) -> float: 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." + # eigene Hardware-Stromgrenze des Ladepunkts auch beim Entladen durchsetzen, nicht nur + # beim Laden - check_cp_max_current() behandelt negative Werte bereits korrekt + # (Vorzeichen wird ueber abs()/sign wieder hergestellt). + required_current = self.check_cp_max_current(required_current, phases) else: if self.data.get.max_charge_power / phases / 230 < required_current: required_current = self.data.get.max_charge_power / phases / 230 diff --git a/packages/control/chargepoint/chargepoint_test.py b/packages/control/chargepoint/chargepoint_test.py index 57a746649b..c7a036e363 100644 --- a/packages/control/chargepoint/chargepoint_test.py +++ b/packages/control/chargepoint/chargepoint_test.py @@ -5,6 +5,8 @@ from control import data from control.chargelog import chargelog +from control.chargemode import Chargemode +from control.chargepoint.charging_type import ChargingType from control.chargepoint.chargepoint import Chargepoint from control.chargepoint.chargepoint_state import ChargepointState from control.chargepoint.chargepoint_template import CpTemplate @@ -66,6 +68,33 @@ def test_check_min_max_current(required_current, phases, expected_required_curre assert ret == expected_required_current +@pytest.mark.parametrize("required_current, max_discharge_power, dc_max_current, expected_required_current", + [ + pytest.param(-50, -30000, 25, -25, + id="Entladung durch CP-Hardwaregrenze begrenzt"), + pytest.param(-10, -30000, 25, -10, + id="Entladung innerhalb der CP-Hardwaregrenze"), + ]) +def test_check_min_max_current_bidi_discharge_respects_cp_hardware_limit( + required_current, max_discharge_power, dc_max_current, expected_required_current): + """ Beim Entladen (BIDI_CHARGING, required_current < 0) muss die eigene Hardware-Stromgrenze des + Ladepunkts (dc_max_current) genauso durchgesetzt werden wie beim Laden - vor diesem Fix wurde + check_cp_max_current() im Entlade-Zweig gar nicht aufgerufen (siehe #3795). """ + # setup + cp = Chargepoint(0, None) + cp.template = CpTemplate() + cp.template.data.charging_type = ChargingType.DC.value + cp.template.data.dc_max_current = dc_max_current + cp.data.control_parameter.submode = Chargemode.BIDI_CHARGING + cp.data.get.max_discharge_power = max_discharge_power + + # evaluation + ret = cp.check_min_max_current(required_current, 1) + + # assertion + assert ret == expected_required_current + + @dataclass class Params: name: str diff --git a/packages/control/counter.py b/packages/control/counter.py index 8736fc0150..3e9634ea46 100644 --- a/packages/control/counter.py +++ b/packages/control/counter.py @@ -92,6 +92,11 @@ class Set: released_surplus: float = field(default=0, metadata={"topic": "set/released_surplus"}) raw_power_left: Optional[float] = 0 raw_currents_left: List[float] = field(default_factory=currents_list_factory) + + # Bidi Entladung + raw_exported_power_left: Optional[float] = 0 + raw_exported_currents_left: List[float] = field(default_factory=currents_list_factory) + surplus_power_left: float = 0 @@ -177,8 +182,16 @@ def _get_loadmanagement_state(self) -> bool: # tested def _set_current_left(self, loadmanagement_available: bool) -> None: + """ Ermittelt getrennt die verbleibenden Ströme für Laden und Entladen. + Ladende CPs werden bei der Bezugsreserve berücksichtigt. + Entladende CPs bei der Einspeisereserve. + -> Einspeisung erhöht die verbleibenden Ströme für Laden + -> Bezug erhöht die verbleibenden Ströme für Entladen + """ if loadmanagement_available: currents_raw = self.data.get.currents + currents_exported_raw = self.data.get.currents + load_keys = data.data.counter_all_data.get_loads_of_counter(f"counter{self.num}") for load_key in load_keys: if "cp" in load_key: @@ -197,18 +210,44 @@ def _set_current_left(self, loadmanagement_available: bool) -> None: load.data.get.currents) except KeyError: element_current = [get_medium_charging_current(load.data.get.currents)]*3 - currents_raw = list(map(operator.sub, currents_raw, element_current)) - currents_raw = list(map(operator.sub, self.data.config.max_currents, currents_raw)) - if min(currents_raw) < 0: - log.debug(f"Verbleibende Ströme: {currents_raw}, Überbelastung wird durch Hausverbrauch verursacht") - currents_raw = [max(currents_raw[i], 0) for i in range(0, 3)] - self.data.set.raw_currents_left = currents_raw + # Vorzeichen anhand der Gesamtleistung entscheiden, nicht anhand einzelner Phasenströme + # (min(element_current) < 0): bei ein-/zweiphasigem Laden zeigen ungenutzte Phasen oft ein + # geringes negatives Messrauschen (zB -0.05A), das sonst den ganzen Load faelschlich als + # Einspeisung einordnet - der eigene Ladestrom wird dann nirgends von currents_raw + # abgezogen und das Lastmanagement haelt die eigene Ladung faelschlich fuer Fremdlast. + if load.data.get.power < 0: + # nur Hausverbraucher ohne Einspeisung + currents_exported_raw = list(map(operator.sub, currents_exported_raw, element_current)) + else: + # nur Hausverbraucher und Einspeisung + currents_raw = list(map(operator.sub, currents_raw, element_current)) + + raw_currents_left = list(map(operator.sub, self.data.config.max_currents, currents_raw)) + raw_exported_currents_left = list(map(operator.add, self.data.config.max_currents, currents_exported_raw)) + + if min(raw_currents_left) < 0: + log.debug( + f"Verbleibende Ströme Laden: " + f"{raw_currents_left}, Überbelastung wird durch Hausverbrauch verursacht") + raw_currents_left = [max(raw_currents_left[i], 0) for i in range(0, 3)] + + if min(raw_exported_currents_left) < 0: + log.debug(("Verbleibende Ströme Entladen: " + f"{raw_exported_currents_left}, Überbelastung wird durch Einspeisung verursacht")) + raw_exported_currents_left = [max(raw_exported_currents_left[i], 0) for i in range(0, 3)] + + self.data.set.raw_currents_left = raw_currents_left + self.data.set.raw_exported_currents_left = raw_exported_currents_left log.info(f'Verbleibende Ströme an Zähler {self.num}: {self.data.set.raw_currents_left}A') + log.info((f'Verbleibende exportierte Ströme an Zähler {self.num}: ' + f'{self.data.set.raw_exported_currents_left}A')) else: self.data.set.raw_currents_left = [self.data.config.max_power_errorcase/230/3]*3 + self.data.set.raw_exported_currents_left = [self.data.config.max_power_errorcase/230/3]*3 log.info(f'Verbleibende Ströme an Zähler {self.num} (Fehlerfall): {self.data.set.raw_currents_left}A') # tested + def get_unbalanced_load_exceeding(self, raw_currents_left: List[float]) -> List[float]: """gibt eine Liste zurück, die für jede Phase angibt, um wie viel Ampere die Schieflast überschritten wurde. So können gezielt Fahrzeuge reduziert werden, die auf dieser/n Phase(n) laden. Die Phase mit dem höchsten @@ -227,7 +266,11 @@ def _set_power_left(self, loadmanagement_available: bool) -> None: if f'counter{self.num}' == data.data.counter_all_data.get_evu_counter_str(): if loadmanagement_available: power_raw = self.data.get.power + power_exported_raw = self.data.get.power for cp in data.data.cp_data.values(): + if cp.data.get.power < 0: + power_exported_raw -= cp.data.get.power + continue power_raw -= cp.data.get.power for consumer in data.data.consumer_data.values(): # Wenn der Verbraucher nicht angesteuert werden darf, im LM als nicht veränderbaren Verbrauch @@ -238,28 +281,47 @@ def _set_power_left(self, loadmanagement_available: bool) -> None: log.debug(f"Verbraucher {consumer.num} als unveränderlichen Verbrauch im LM " f"mit {consumer.data.get.power}W berücksichtigt.") self.data.set.raw_power_left = self.data.config.max_total_power - power_raw + self.data.set.raw_exported_power_left = self.data.config.max_total_power + power_exported_raw log.info(f'Verbleibende Leistung an Zähler {self.num}: {self.data.set.raw_power_left}W') + log.info((f'Verbleibende exportierte Leistung an Zähler {self.num}: ' + f'{self.data.set.raw_exported_power_left}W')) else: self.data.set.raw_power_left = self.data.config.max_power_errorcase + self.data.set.raw_exported_power_left = self.data.config.max_power_errorcase log.info(f'Verbleibende Leistung an Zähler {self.num} (Fehlerfall): {self.data.set.raw_power_left}W') + log.info((f'Verbleibende exportierte Leistung an Zähler {self.num} (Fehlerfall): ' + f'{self.data.set.raw_exported_power_left}W')) else: self.data.set.raw_power_left = None + self.data.set.raw_exported_power_left = None def update_values_left(self, diffs, cp_voltage: float) -> None: - # Mittelwert der Spannungen verwenden, um Phasenverdrehung zu kompensieren - # (Probleme bei einphasig angeschlossenen Wallboxen) - self.data.set.raw_currents_left = list(map(operator.sub, self.data.set.raw_currents_left, diffs)) - if self.data.set.raw_power_left is not None: - self.data.set.raw_power_left -= sum([c * cp_voltage for c in diffs]) - log.debug(f'Zähler {self.num}: {self.data.set.raw_currents_left}A verbleibende Ströme, ' - f'{self.data.set.raw_power_left}W verbleibende Leistung') + self._update_raw_values(diffs, cp_voltage, surplus=False) def update_surplus_values_left(self, diffs, cp_voltage: float) -> None: + self._update_raw_values(diffs, cp_voltage, surplus=True) + + def _update_raw_values(self, diffs, cp_voltage: float, surplus: bool = False) -> None: + # Mittelwert der Spannungen verwenden, um Phasenverdrehung zu kompensieren + # (Probleme bei einphasig angeschlossenen Wallboxen) self.data.set.raw_currents_left = list(map(operator.sub, self.data.set.raw_currents_left, diffs)) - if self.data.set.surplus_power_left is not None: - self.data.set.surplus_power_left -= sum([c * cp_voltage for c in diffs]) - log.debug(f'Zähler {self.num}: {self.data.set.raw_currents_left}A verbleibende Ströme, ' - f'{self.data.set.surplus_power_left}W verbleibender Überschuss') + self.data.set.raw_exported_currents_left = list( + map(operator.add, self.data.set.raw_exported_currents_left, diffs)) + + if surplus: + if self.data.set.surplus_power_left is not None: + self.data.set.surplus_power_left -= sum([c * cp_voltage for c in diffs]) + log.debug(f'Zähler {self.num}: {self.data.set.raw_currents_left}A verbleibende Ströme, ' + f'{self.data.set.surplus_power_left}W verbleibender Überschuss') + else: + if self.data.set.raw_power_left is not None: + self.data.set.raw_power_left -= sum([c * cp_voltage for c in diffs]) + if self.data.set.raw_exported_power_left is not None: + self.data.set.raw_exported_power_left += sum([c * cp_voltage for c in diffs]) + log.debug(f'Zähler {self.num}: {self.data.set.raw_currents_left}A verbleibende Ströme, ' + f'{self.data.set.raw_exported_currents_left}A verbleibende exportierte Ströme, ' + f'{self.data.set.raw_power_left}W verbleibende Leistung, ' + f'{self.data.set.raw_exported_power_left}W verbleibende exportierte Leistung') def calc_surplus(self): # reservierte Leistung wird nicht berücksichtigt, weil diese noch verwendet werden kann, bis die EV @@ -500,6 +562,12 @@ def switch_off_check_timer(self, load: Load) -> None: except Exception: log.exception("Fehler im allgemeinen PV-Modul") + def _get_switch_off_threshold_by_load(self, load: Load) -> int: + if isinstance(load, Chargepoint): + return data.data.general_data.data.chargemode_config.surplus.vehicle.switch_off_threshold + else: + return data.data.general_data.data.chargemode_config.surplus.consumer.switch_off_threshold + def calc_switch_off_threshold(self, load: Load) -> float: surplus_config = data.data.general_data.data.chargemode_config.surplus control_parameter = load.data.control_parameter @@ -510,7 +578,7 @@ def calc_switch_off_threshold(self, load: Load) -> float: threshold = (-surplus_config.feed_in_yield + surplus_config.vehicle.switch_on_threshold*control_parameter.phases) else: - threshold = surplus_config.consumer.switch_off_threshold + threshold = self._get_switch_off_threshold_by_load(load) return threshold def calc_switch_off(self, load: Load) -> Tuple[float, float]: diff --git a/packages/control/counter_all/counter_all.py b/packages/control/counter_all/counter_all.py index 29a31d430f..2e8b05f724 100644 --- a/packages/control/counter_all/counter_all.py +++ b/packages/control/counter_all/counter_all.py @@ -167,7 +167,7 @@ def _calc_home_consumption(self) -> Tuple[float, Dict]: home_consumption = 0.0 # Rekursion startet immer beim EVU-Zähler. - home_consumption = self._calc_home_consumption_from_counter(evu_element, CounterMode.NOT_HOME_CONSUMPTION.value) + home_consumption = self._calc_home_consumption_from_counter(evu_element, CounterMode.HOME_CONSUMPTION.value) home_consumption -= self.data.set.smarthome_power_excluded_from_home_consumption @@ -206,8 +206,8 @@ def _is_home_consumption_counter_by_id(self, counter_id: int) -> str: parent = self.get_entry_of_parent(counter_id) if not parent or parent["type"] != ComponentType.COUNTER.value: - # Auto am Wurzel-Zähler entspricht dem bisherigen Startwert CounterMode.NOT_HOME_CONSUMPTION. - return CounterMode.NOT_HOME_CONSUMPTION.value + # Auto am Wurzel-Zähler entspricht dem bisherigen Startwert CounterMode.HOME_CONSUMPTION. + return CounterMode.HOME_CONSUMPTION.value return self._is_home_consumption_counter_by_id(parent["id"]) diff --git a/packages/control/counter_all/counter_all_data.py b/packages/control/counter_all/counter_all_data.py index 3806edb738..a61ba69c3c 100644 --- a/packages/control/counter_all/counter_all_data.py +++ b/packages/control/counter_all/counter_all_data.py @@ -1,7 +1,8 @@ from dataclasses import dataclass, field -from typing import Callable, Dict, Generator, List, Protocol, Tuple, Union +from typing import Callable, Dict, Generator, List, Optional, Protocol, Tuple, Union from control.chargepoint.chargepoint import Chargepoint +from control.consumer.usage import ConsumerUsage from dataclass_utils.factories import empty_list_factory from modules.common.component_type import ComponentType @@ -93,11 +94,20 @@ def hierarchy_add_item_below(self, new_id: int, new_type: ComponentType, id_to_f def hierarchy_add_item_below_evu(self, new_id: int, new_type: ComponentType) -> None: ... def hierarchy_remove_item(self, id_to_find: int, keep_children: bool = True) -> None: ... + def update_linked_counter_hierarchy(self, + consumer_id: int, + old_counter_id: Optional[int], + new_counter_id: Optional[int]) -> bool: ... + class LoadmanagementPrioProtocol(Protocol): @property def data(self) -> CounterAllData: ... def add_loadmanagement_prio_item(self, type: ComponentType, id: int) -> None: ... + def update_consumer_loadmanagement_prio(self, consumer_id: int, usage_type: ConsumerUsage) -> bool: ... + def _has_loadmanagement_prio_item( + self, type: ComponentType, id: int, entries: List[Dict]) -> bool: ... + def remove_loadmanagement_prio_item(self, type: ComponentType, id: int) -> None: ... def _remove_loadmanagement_prio_item(self, type: ComponentType, id: int, entry: List[Dict]) -> bool: ... def sort_cps_by_loadmanagement_prios_nested(self, filtered_cps: List[Chargepoint]) -> List[List[Chargepoint]]: ... diff --git a/packages/control/counter_all/hierarchy.py b/packages/control/counter_all/hierarchy.py index deb21187a1..25c0770b76 100644 --- a/packages/control/counter_all/hierarchy.py +++ b/packages/control/counter_all/hierarchy.py @@ -11,6 +11,55 @@ class HierarchyMixin: + def _is_entry_of_type(self: HierarchyProtocol, id_to_find: int, type_to_find: ComponentType) -> bool: + entry = self.get_entry_of_element(id_to_find) + return bool(entry) and entry.get("type") == type_to_find.value + + def _get_parent_counter_id_of_consumer(self: HierarchyProtocol, consumer_id: int) -> Optional[int]: + parent = self.get_entry_of_parent(consumer_id) + if not parent or parent.get("type") != ComponentType.COUNTER.value: + return None + return parent["id"] + + def _add_unlinked_counter_below_consumer_parent_if_needed(self: HierarchyProtocol, + consumer_id: int, + counter_id: int) -> bool: + if self._is_entry_of_type(counter_id, ComponentType.COUNTER): + return False + parent_counter_id = self._get_parent_counter_id_of_consumer(consumer_id) + if parent_counter_id is None: + return False + self.hierarchy_add_item_below(counter_id, ComponentType.COUNTER, parent_counter_id) + return True + + def _remove_linked_counter_from_hierarchy_if_present(self: HierarchyProtocol, counter_id: int) -> bool: + counter_entry = self.get_entry_of_element(counter_id) + if not counter_entry or counter_entry.get("type") != ComponentType.COUNTER.value: + return False + parent_counter_id = self.get_entry_of_parent(counter_id).get("id") + if parent_counter_id is None: + return False + self.hierarchy_remove_item(counter_id) + return True + + def update_linked_counter_hierarchy(self: HierarchyProtocol, + consumer_id: int, + old_counter_id: Optional[int], + new_counter_id: Optional[int]) -> bool: + hierarchy_changed = False + + if not self._is_entry_of_type(consumer_id, ComponentType.CONSUMER): + return False + + if new_counter_id is not None: + hierarchy_changed = self._remove_linked_counter_from_hierarchy_if_present(new_counter_id) + + if old_counter_id is not None and old_counter_id != new_counter_id: + if self._add_unlinked_counter_below_consumer_parent_if_needed(consumer_id, old_counter_id): + hierarchy_changed = True + + return hierarchy_changed + def get_all_elements_without_children(self: HierarchyProtocol, id: int) -> List[Dict]: self.childless = [] self._get_all_elements_without_children_recursive(self.get_entry_of_element(id)) diff --git a/packages/control/counter_all/loadmanagement_prio.py b/packages/control/counter_all/loadmanagement_prio.py index 5bfe377d6d..571f189f2d 100644 --- a/packages/control/counter_all/loadmanagement_prio.py +++ b/packages/control/counter_all/loadmanagement_prio.py @@ -2,6 +2,7 @@ from typing import Dict, Generator, List, Tuple from control.chargepoint.chargepoint import Chargepoint +from control.consumer.usage import ConsumerUsage, NOT_CONTROLLED from control.counter_all.counter_all_data import LoadmanagementPrioProtocol from modules.common.component_type import ComponentType @@ -13,6 +14,30 @@ class LoadmanagementPrioMixin: def add_loadmanagement_prio_item(self: LoadmanagementPrioProtocol, type: ComponentType, id: int) -> None: self.data.get.loadmanagement_prios.append({"type": type.value, "id": id}) + def update_consumer_loadmanagement_prio( + self: LoadmanagementPrioProtocol, consumer_id: int, usage_type: ConsumerUsage) -> bool: + consumer_in_prios = self._has_loadmanagement_prio_item( + ComponentType.CONSUMER, consumer_id, self.data.get.loadmanagement_prios) + if usage_type in NOT_CONTROLLED: + if consumer_in_prios: + self.remove_loadmanagement_prio_item(ComponentType.CONSUMER, consumer_id) + return True + elif consumer_in_prios is False: + self.add_loadmanagement_prio_item(ComponentType.CONSUMER, consumer_id) + return True + return False + + def _has_loadmanagement_prio_item(self: LoadmanagementPrioProtocol, + type: ComponentType, + id: int, + entries: List[Dict]) -> bool: + for entry in entries: + if entry["type"] == type.value and entry["id"] == id: + return True + if entry["type"] == "group" and self._has_loadmanagement_prio_item(type, id, entry["children"]): + return True + return False + def remove_loadmanagement_prio_item(self: LoadmanagementPrioProtocol, type: ComponentType, id: int) -> None: if self._remove_loadmanagement_prio_item(type, id, self.data.get.loadmanagement_prios) is False: # Kein Grund, die Löschung des restlichen Elements (Topics, Hierarchie) abzubrechen, wenn der diff --git a/packages/control/counter_all/loadmanagement_prio_test.py b/packages/control/counter_all/loadmanagement_prio_test.py index 5bb780ed92..3dc1b7e640 100644 --- a/packages/control/counter_all/loadmanagement_prio_test.py +++ b/packages/control/counter_all/loadmanagement_prio_test.py @@ -5,6 +5,7 @@ import pytest from control.chargepoint.chargepoint import Chargepoint +from control.consumer.usage import ConsumerUsage from control.counter_all.counter_all import CounterAll from modules.common.component_type import ComponentType @@ -76,6 +77,36 @@ def test_add_item(loadmanagement_prios: List[Dict], assert c.data.get.loadmanagement_prios == expected_loadmanagement_prios +@pytest.mark.parametrize( + "loadmanagement_prios, usage_type, expected_loadmanagement_prios, expected_changed", + [ + pytest.param([], ConsumerUsage.CONTINUOUS, [{"type": "consumer", "id": 2}], True, id="add consumer"), + pytest.param([{"type": "consumer", "id": 2}], ConsumerUsage.CONTINUOUS, + [{"type": "consumer", "id": 2}], False, id="do not add duplicate"), + pytest.param([{ + "type": "group", + "label": "Verbraucher", + "children": [{"type": "consumer", "id": 2}], + }], ConsumerUsage.METER_ONLY, [], True, id="remove consumer from group"), + pytest.param([], ConsumerUsage.METER_ONLY, [], False, id="ignore absent meter"), + pytest.param([{"type": "consumer", "id": 2}], ConsumerUsage.SELF_CONTROLLED, [], True, + id="remove self-controlled consumer"), + pytest.param([], ConsumerUsage.SELF_CONTROLLED, [], False, id="ignore absent self-controlled consumer"), + ], +) +def test_update_consumer_loadmanagement_prio(loadmanagement_prios: List[Dict], + usage_type: ConsumerUsage, + expected_loadmanagement_prios: List[Dict], + expected_changed: bool): + c = CounterAll() + c.data.get.loadmanagement_prios = loadmanagement_prios + + changed = c.update_consumer_loadmanagement_prio(2, usage_type) + + assert c.data.get.loadmanagement_prios == expected_loadmanagement_prios + assert changed is expected_changed + + @pytest.mark.parametrize( "loadmanagement_prios, type, id, expected_loadmanagement_prios", [ diff --git a/packages/control/counter_test.py b/packages/control/counter_test.py index c1bd1ff754..b8550a815a 100644 --- a/packages/control/counter_test.py +++ b/packages/control/counter_test.py @@ -111,6 +111,30 @@ def test_set_current_left(loadmanagement_available: bool, assert counter.data.set.raw_currents_left == expected_raw_currents_left +def test_set_current_left_nets_out_own_current_despite_noise_on_unused_phase(monkeypatch, data_): + """ Ein einphasig ladender Ladepunkt zeigt auf ungenutzten Phasen oft ein geringes negatives + Messrauschen (zB -0.05A). min(element_current) < 0 hat das faelschlich als Einspeisung eingeordnet, + wodurch der eigene Ladestrom nirgends von currents_raw abgezogen wurde - das Lastmanagement hielt + die eigene Ladung dann faelschlich fuer Fremdlast (siehe Matts Log in Discussion #3908).""" + # setup + get_loads_of_counter_mock = Mock(return_value=["cp4"]) + monkeypatch.setattr(data.data.counter_all_data, "get_loads_of_counter", get_loads_of_counter_mock) + data.data.cp_data["cp4"].data.config.phase_1 = 1 + data.data.cp_data["cp4"].data.get.currents = [20, 0.05, -0.05] + data.data.cp_data["cp4"].data.get.power = 4600 + counter = Counter(0) + counter.data.config.max_currents = [35]*3 + counter.data.config.max_total_power = 35*3*230 + counter.data.config.max_power_errorcase = 7000 + counter.data.get.currents = [10, -5, -3] + + # execution + counter._set_current_left(True) + + # evaluation + assert counter.data.set.raw_currents_left == [45, 40.05, 37.95] + + @dataclass class Params: name: str @@ -199,6 +223,24 @@ def test_control_range(control_range, evu_power, expected_range_offset, general_ assert range_offset == expected_range_offset +@pytest.mark.parametrize("load_factory, expected", + [pytest.param(lambda: Chargepoint(0, None), -200, id="Ladepunkt"), + pytest.param(lambda: Consumer(0), -50, id="Verbraucher")]) +def test_calc_switch_off_threshold_by_load(load_factory, expected: float, general_data_fixture): + # setup + surplus_config = data.data.general_data.data.chargemode_config.surplus + surplus_config.feed_in_limit = False + surplus_config.vehicle.switch_off_threshold = -200 + surplus_config.consumer.switch_off_threshold = -50 + c = Counter(0) + + # execution + threshold = c.calc_switch_off_threshold(load_factory()) + + # evaluation + assert threshold == expected + + def test_reset_switch_on_off_ignores_stale_timestamp_without_delay_state(general_data_fixture, monkeypatch): # setup evu_counter = Counter(0) diff --git a/packages/control/ev/charge_template.py b/packages/control/ev/charge_template.py index d6a309064f..db8bd9ba3e 100644 --- a/packages/control/ev/charge_template.py +++ b/packages/control/ev/charge_template.py @@ -205,11 +205,13 @@ def time_charging(self, SOC_REACHED = "Keine Ladung, da das Ladeziel bereits erreicht wurde." AMOUNT_REACHED = "Keine Ladung, da die Energiemenge bereits geladen wurde." + INSTANT_CHARGING_BIDI = "Fahrzeug entlädt." def instant_charging(self, soc: Optional[float], used_amount: float, - charging_type: str) -> Tuple[int, ChargemodeEnum, Optional[str], int]: + charging_type: str, + bidi_state: BidiState) -> Tuple[int, ChargemodeEnum, Optional[str], int]: """ prüft, ob die Lademengenbegrenzung erreicht wurde und setzt entsprechend den Ladestrom. """ message = None @@ -231,6 +233,15 @@ def instant_charging(self, current = 0 sub_mode = ChargemodeEnum.STOP message = self.AMOUNT_REACHED + elif current < 0: + if bidi_state == BidiState.BIDI_CAPABLE: + sub_mode = ChargemodeEnum.BIDI_CHARGING + message = self.INSTANT_CHARGING_BIDI + else: + message = bidi_state.value + current = 0 + sub_mode = ChargemodeEnum.STOP + return current, sub_mode, message, phases except Exception: log.exception("Fehler im ev-Modul "+str(self.data.id)) diff --git a/packages/control/ev/charge_template_test.py b/packages/control/ev/charge_template_test.py index 9136ba4ac5..3b860bd5bc 100644 --- a/packages/control/ev/charge_template_test.py +++ b/packages/control/ev/charge_template_test.py @@ -169,7 +169,7 @@ def test_instant_charging(selected: str, current_soc: float, used_amount: float, ct.data.chargemode.instant_charging.limit.amount = 1000 # execution - ret = ct.instant_charging(current_soc, used_amount, ChargingType.AC.value) + ret = ct.instant_charging(current_soc, used_amount, ChargingType.AC.value, BidiState.CP_NOT_BIDI_CAPABLE) # evaluation assert ret == expected diff --git a/packages/control/ev/ev.py b/packages/control/ev/ev.py index 0a1e77abcf..7e24057424 100644 --- a/packages/control/ev/ev.py +++ b/packages/control/ev/ev.py @@ -195,7 +195,9 @@ def get_required_current(self, required_current, submode, tmp_message, phases = charge_template.instant_charging( self.data.get.soc, imported_since_plugged, - charging_type) + charging_type, + bidi + ) elif charge_template.data.chargemode.selected == Chargemode.PV_CHARGING: required_current, submode, tmp_message, phases = charge_template.pv_charging( self.data.get.soc, control_parameter.min_current, charging_type, imported_since_plugged) diff --git a/packages/control/io_device.py b/packages/control/io_device.py index 1b2b4839f7..74399b4e2c 100644 --- a/packages/control/io_device.py +++ b/packages/control/io_device.py @@ -1,7 +1,11 @@ from dataclasses import dataclass, field -from typing import Dict, Optional, Tuple, Union +import logging +from typing import Dict, Iterator, Optional, Tuple, Union +from control import data from control.limiting_value import LoadmanagementLimit from helpermodules.constants import NO_ERROR +from modules.common.configurable_io import ConfigurableIo +from modules.common.fault_state import FaultStateContext from modules.io_actions.controllable_consumers.dimming.api_eebus import DimmingEebus from modules.io_actions.controllable_consumers.dimming.api_io import DimmingIo @@ -12,6 +16,18 @@ from modules.io_actions.generator_systems.stepwise_control.api_eebus import StepwiseControlEebus from modules.io_actions.generator_systems.stepwise_control.api_io import StepwiseControlIo +log = logging.getLogger(__name__) + +IoAction = Union[ + DimmingIo, + DimmingEebus, + DimmingDirectControl, + RippleControlReceiver, + StepwiseControlEebus, + StepwiseControlIo, + LoadManager, +] + @dataclass class Get: @@ -57,53 +73,69 @@ def __init__(self, num: Union[int, str]): class IoActions: def __init__(self): - self.actions: Dict[int, Union[DimmingIo, DimmingEebus, DimmingDirectControl, - RippleControlReceiver, StepwiseControlEebus, StepwiseControlIo, - LoadManager]] = {} + self.actions: Dict[int, IoAction] = {} - def setup(self): + def iter_actions_with_io_device(self) -> Iterator[Tuple[IoAction, ConfigurableIo]]: for action in self.actions.values(): - action.setup() + io_device = data.data.system_data.get(f"io{action.config.configuration.io_device}") + if not isinstance(io_device, ConfigurableIo): + log.error(f"I/O device io{action.config.configuration.io_device} not found.") + continue + yield action, io_device - def dimming_get_import_power_left(self, device: Dict) -> Tuple[Optional[float], LoadmanagementLimit]: - for action in self.actions.values(): - if isinstance(action, (DimmingIo, DimmingEebus)): - for d in action.config.configuration.devices: - if device == d: - return action.dimming_get_import_power_left() + def setup(self): + for action, io_device in self.iter_actions_with_io_device(): + with FaultStateContext(io_device.fault_state, update_always=False): + action.setup() + + def dimming_get_import_power_left(self, + device: Dict[str, Union[int, str]] + ) -> Tuple[Optional[float], LoadmanagementLimit]: + for action, io_device in self.iter_actions_with_io_device(): + with FaultStateContext(io_device.fault_state, update_always=False): + if isinstance(action, (DimmingIo, DimmingEebus)): + for d in action.config.configuration.devices: + if device == d: + return action.dimming_get_import_power_left() else: return None, LoadmanagementLimit(None, None) - def dimming_set_import_power_left(self, device: Dict, used_power: float) -> Optional[float]: - for action in self.actions.values(): - if isinstance(action, (DimmingIo, DimmingEebus)): - for d in action.config.configuration.devices: - if d == device: - return action.dimming_set_import_power_left(used_power) - - def dimming_via_direct_control(self, device: Dict) -> Tuple[Optional[float], LoadmanagementLimit]: - for action in self.actions.values(): - if isinstance(action, DimmingDirectControl): - for d in action.config.configuration.devices: - if device == d: - return action.dimming_via_direct_control() + def dimming_set_import_power_left(self, + device: Dict[str, Union[int, str]], used_power: float) -> Optional[float]: + for action, io_device in self.iter_actions_with_io_device(): + with FaultStateContext(io_device.fault_state, update_always=False): + if isinstance(action, (DimmingIo, DimmingEebus)): + for d in action.config.configuration.devices: + if d == device: + return action.dimming_set_import_power_left(used_power) + + def dimming_via_direct_control(self, + device: Dict[str, Union[int, str]]) -> Tuple[Optional[float], LoadmanagementLimit]: + for action, io_device in self.iter_actions_with_io_device(): + with FaultStateContext(io_device.fault_state, update_always=False): + if isinstance(action, DimmingDirectControl): + for d in action.config.configuration.devices: + if device == d: + return action.dimming_via_direct_control() else: return None, LoadmanagementLimit(None, None) - def ripple_control_receiver(self, device: Dict) -> Tuple[float, LoadmanagementLimit]: - for action in self.actions.values(): - if isinstance(action, RippleControlReceiver): - for d in action.config.configuration.devices: - if device == d: - return action.ripple_control_receiver() + def ripple_control_receiver(self, device: Dict[str, Union[int, str]]) -> Tuple[float, LoadmanagementLimit]: + for action, io_device in self.iter_actions_with_io_device(): + with FaultStateContext(io_device.fault_state, update_always=False): + if isinstance(action, RippleControlReceiver): + for d in action.config.configuration.devices: + if device == d: + return action.ripple_control_receiver() else: return 1, LoadmanagementLimit(None, None) def stepwise_control(self, device_id: int) -> Tuple[Optional[float], LoadmanagementLimit]: - for action in self.actions.values(): - if isinstance(action, (StepwiseControlEebus, StepwiseControlIo)): - if device_id in [component["id"] for component in action.config.configuration.devices]: - return action.control_stepwise() + for action, io_device in self.iter_actions_with_io_device(): + with FaultStateContext(io_device.fault_state, update_always=False): + if isinstance(action, (StepwiseControlEebus, StepwiseControlIo)): + if device_id in [component["id"] for component in action.config.configuration.devices]: + return action.control_stepwise() else: return None, LoadmanagementLimit(None, None) diff --git a/packages/control/load.py b/packages/control/load.py index 48dfa99fb3..ec24a305f6 100644 --- a/packages/control/load.py +++ b/packages/control/load.py @@ -9,31 +9,26 @@ log = logging.getLogger(__name__) +# Zustände, in denen reserved_surplus/released_surplus gerade eine laufende Reservierung halten (siehe die +# jeweiligen += beim Eintritt und -= beim Verlassen dieser Zustände in counter.py/ev.py/chargepoint.py). +# NO_CHARGING_ALLOWED gehört bewusst nicht dazu: jede Reservierung wird spätestens beim Übergang in diesen +# Zustand bereits wieder freigegeben (siehe zB Counter.reset_switch_on_off()), er wird hier also nie mit einer +# noch offenen Reservierung erreicht. +RESERVATION_HOLDING_STATES = (ChargepointState.PERFORMING_PHASE_SWITCH, + ChargepointState.PHASE_SWITCH_DELAY, + ChargepointState.SWITCH_OFF_DELAY, + ChargepointState.SWITCH_ON_DELAY) + def reset_pv_data_if_no_active_delays() -> None: - """ Wenn keine EV angesteckt sind oder keine Verzögerungen aktiv sind, werden die Algorithmus-Werte + """ Wenn keine Komponente gerade eine Reservierung offen hält, werden die Algorithmus-Werte zurückgesetzt. (dient der Robustheit) """ try: for load in list(data.data.cp_data.values()) + list(data.data.consumer_data.values()): try: - control_parameter = load.data.control_parameter - if isinstance(load, Chargepoint): - if (not load.data.get.plug_state or - (control_parameter.state != ChargepointState.PERFORMING_PHASE_SWITCH and - control_parameter.state != ChargepointState.PHASE_SWITCH_DELAY and - control_parameter.state != ChargepointState.SWITCH_OFF_DELAY and - control_parameter.state != ChargepointState.SWITCH_ON_DELAY and - control_parameter.state != ChargepointState.NO_CHARGING_ALLOWED)): - continue - break - else: - # Verbraucher haben keinen Plug-Status. - # Nur laufende Ein-/Ausschaltverzögerungen müssen den Reset verhindern. - if (control_parameter.state != ChargepointState.SWITCH_OFF_DELAY and - control_parameter.state != ChargepointState.SWITCH_ON_DELAY): - continue + if load.data.control_parameter.state in RESERVATION_HOLDING_STATES: break except Exception: log.exception(f"Fehler beim Bereinigen der Werte für Ladepunkt/Verbraucher {load.num}") diff --git a/packages/control/load_test.py b/packages/control/load_test.py index c9aa4d7e18..0c13dd8e4e 100644 --- a/packages/control/load_test.py +++ b/packages/control/load_test.py @@ -1,6 +1,8 @@ from typing import Dict from unittest.mock import Mock +import pytest + from control import data, load from control.chargepoint.chargepoint import Chargepoint from control.chargepoint.chargepoint_state import ChargepointState @@ -38,27 +40,27 @@ def test_do_not_reset_pv_data_when_consumer_switch_off_delay_active(): evu_counter.reset_pv_data.assert_not_called() -def test_do_not_reset_pv_data_with_plugged_chargepoint_no_charging_allowed(): +def test_reset_pv_data_with_chargepoint_no_charging_allowed(): + # eine wartende, aber nicht (mehr) reservierte Komponente darf den robustness-Reset nicht dauerhaft + # blockieren - jede Reservierung wird bereits beim Übergang in NO_CHARGING_ALLOWED freigegeben. cp = Mock(spec=Chargepoint, num="1") - cp.data = Mock( - control_parameter=Mock(state=ChargepointState.NO_CHARGING_ALLOWED), - get=Mock(plug_state=True), - ) + cp.data = Mock(control_parameter=Mock(state=ChargepointState.NO_CHARGING_ALLOWED)) evu_counter = _setup_control_data({"cp1": cp}, {}) load.reset_pv_data_if_no_active_delays() - evu_counter.reset_pv_data.assert_not_called() + evu_counter.reset_pv_data.assert_called_once() -def test_reset_pv_data_with_unplugged_chargepoint_no_charging_allowed(): +@pytest.mark.parametrize("state", [ChargepointState.PERFORMING_PHASE_SWITCH, + ChargepointState.PHASE_SWITCH_DELAY, + ChargepointState.SWITCH_OFF_DELAY, + ChargepointState.SWITCH_ON_DELAY]) +def test_do_not_reset_pv_data_with_chargepoint_holding_a_reservation(state): cp = Mock(spec=Chargepoint, num="1") - cp.data = Mock( - control_parameter=Mock(state=ChargepointState.NO_CHARGING_ALLOWED), - get=Mock(plug_state=False), - ) + cp.data = Mock(control_parameter=Mock(state=state)) evu_counter = _setup_control_data({"cp1": cp}, {}) load.reset_pv_data_if_no_active_delays() - evu_counter.reset_pv_data.assert_called_once() + evu_counter.reset_pv_data.assert_not_called() diff --git a/packages/control/loadmanagement.py b/packages/control/loadmanagement.py index 764e3332f0..e19f14d2c7 100644 --- a/packages/control/loadmanagement.py +++ b/packages/control/loadmanagement.py @@ -80,6 +80,26 @@ def get_available_currents_surplus(self, limit = new_limit if new_limit.limiting_value is not None else limit return available_currents, limit + def get_available_currents_bidi(self, + missing_currents: List[float], + cp_voltage: float, + counter: Counter, + feed_in: int = 0) -> Tuple[List[float], LoadmanagementLimit]: + raw_currents_left = counter.data.set.raw_currents_left + available_currents, limit = self._limit_by_current(counter, missing_currents, raw_currents_left) + + available_currents, new_limit = self._limit_by_power( + counter, available_currents, cp_voltage, counter.data.set.raw_power_left, feed_in) + limit = new_limit if new_limit.limiting_value is not None else limit + + if f"counter{counter.num}" == data.data.counter_all_data.get_evu_counter_str(): + available_currents, new_limit = self._limit_by_unbalanced_load( + counter, available_currents, raw_currents_left, + len([value for value in missing_currents if value != 0])) + limit = new_limit if new_limit.limiting_value is not None else limit + + return available_currents, limit + def _limit_by_unbalanced_load(self, counter: Counter, available_currents: List[float], @@ -110,21 +130,28 @@ def _limit_by_power(self, # (Probleme bei einphasig angeschlossenen Wallboxen) currents = available_currents.copy() limit = LoadmanagementLimit(None, None) - if raw_power_left is None: + raw_exported_power_left = counter.data.set.raw_exported_power_left + total_power = sum([c * cp_voltage for c in available_currents]) + power_left = raw_power_left if total_power >= 0 else raw_exported_power_left + + if power_left is None: return currents, limit - elif raw_power_left > 0: + elif power_left > 0: if feed_in is not None: - raw_power_left = max(raw_power_left - feed_in, 0) - log.debug(f"Verbleibende Leistung unter Berücksichtigung der Einspeisegrenze: {raw_power_left}W") - if sum([c * cp_voltage for c in available_currents]) > raw_power_left: + power_left = max(power_left - feed_in, 0) + log.debug(f"Verbleibende Leistung unter Berücksichtigung der Einspeisegrenze: {power_left}W") + if abs(total_power) > power_left: + current_sum = sum(available_currents) for i in range(0, 3): try: # Am meisten belastete Phase trägt am meisten zur Leistungsreduktion bei. - currents[i] = available_currents[i] / sum(available_currents) * raw_power_left / cp_voltage + # abs(current_sum) verhindert Vorzeichenwechsel beim Entladen. + currents[i] = available_currents[i] / abs(current_sum) * power_left / cp_voltage + except ZeroDivisionError: # bei einphasig angeschlossenen Wallboxen ist die Spannung der anderen Phasen 0V currents[i] = 0.0 - log.debug(f"Leistungsüberschreitung auf {raw_power_left}W korrigieren: {available_currents}") + log.debug(f"Leistungsüberschreitung auf {power_left}W korrigieren: {available_currents}") limit = LoadmanagementLimit(LimitingValue.POWER.value.format(get_component_name_by_id(counter.num)), LimitingValue.POWER) return currents, limit @@ -138,9 +165,13 @@ def _limit_by_current(self, missing_currents: List[float], raw_currents_left: List[float]) -> Tuple[List[float], LoadmanagementLimit]: available_currents = [0.0]*3 + raw_exported_currents_left = counter.data.set.raw_exported_currents_left limit = LoadmanagementLimit(None, None) for i in range(0, 3): - available_currents[i] = min(missing_currents[i], raw_currents_left[i]) + if missing_currents[i] >= 0: + available_currents[i] = min(missing_currents[i], raw_currents_left[i]) + else: + available_currents[i] = max(missing_currents[i], -raw_exported_currents_left[i]) if available_currents != missing_currents: log.debug(f"Stromüberschreitung {missing_currents}W korrigieren: {available_currents}") limit = LoadmanagementLimit(LimitingValue.CURRENT.value.format(get_component_name_by_id(counter.num)), diff --git a/packages/control/process.py b/packages/control/process.py index 09abe7ab02..607d227c4c 100644 --- a/packages/control/process.py +++ b/packages/control/process.py @@ -19,6 +19,7 @@ from modules.common.abstract_io import AbstractIoDevice from modules.common.configurable_consumer import SetLimitData from modules.common.configurable_device import set_power_limit_wrapper +from modules.common.fault_state import FaultStateContext from modules.common.fault_state_level import FaultStateLevel from modules.io_actions.controllable_consumers.dimming.api_io import DimmingIo from modules.io_actions.controllable_consumers.dimming_direct_control.api import DimmingDirectControl @@ -26,6 +27,7 @@ from modules.io_actions.generator_systems.stepwise_control.api_io import StepwiseControlIo log = logging.getLogger(__name__) +control_command_log = logging.getLogger("steuve_control_command") class Process: @@ -96,31 +98,38 @@ def process_algorithm_results(self) -> None: modules_threads.append(consumer_thread) except Exception: log.exception("Fehler im Process-Modul für Verbaucher "+str(consumer)) - for action in data.data.io_actions.actions.values(): - if isinstance(action, DimmingDirectControl): - for d in action.config.configuration.devices: - if d["type"] == "io": - data.data.io_states[f"io_states{d['id']}"].data.set.digital_output[d["digital_output"]] = ( - action.dimming_via_direct_control()[0] is None # active output (True) if no dimming - ) - if isinstance(action, DimmingIo): - for d in action.config.configuration.devices: - if d["type"] == "io": - data.data.io_states[f"io_states{d['id']}"].data.set.digital_output[d["digital_output"]] = ( - not action.dimming_active() # active output (True) if no dimming - ) - if isinstance(action, (StepwiseControlEebus, StepwiseControlIo)): - # check if passthrough is enabled - if (action.config.configuration.passthrough_enabled and - action.config.configuration.io_output_device is not None): - # find output pattern by value - for pattern in action.config.configuration.output_pattern: - if pattern["value"] == action.control_stepwise()[0]: - # set digital outputs according to matching output_pattern - for output in pattern["matrix"].keys(): - data.data.io_states[ - f"io_states{action.config.configuration.io_output_device}" - ].data.set.digital_output[output] = pattern["matrix"][output] + for action, io_device in data.data.io_actions.iter_actions_with_io_device(): + with FaultStateContext(io_device.fault_state, update_always=False): + try: + if isinstance(action, DimmingDirectControl): + for d in action.config.configuration.devices: + if d["type"] == "io": + digital_output = data.data.io_states[f"io_states{d['id']}"].data.set.digital_output + # active output (True) if no dimming + digital_output[d["digital_output"]] = action.dimming_via_direct_control()[0] is None + if isinstance(action, DimmingIo): + for d in action.config.configuration.devices: + if d["type"] == "io": + digital_output = data.data.io_states[f"io_states{d['id']}"].data.set.digital_output + # active output (True) if no dimming + digital_output[d["digital_output"]] = not action.dimming_active() + if isinstance(action, (StepwiseControlEebus, StepwiseControlIo)): + # check if passthrough is enabled + if (action.config.configuration.passthrough_enabled and + action.config.configuration.io_output_device is not None): + # find output pattern by value + for pattern in action.config.configuration.output_pattern: + if pattern["value"] == action.control_stepwise()[0]: + # set digital outputs according to matching output_pattern + for output in pattern["matrix"].keys(): + data.data.io_states[ + f"io_states{action.config.configuration.io_output_device}" + ].data.set.digital_output[output] = pattern["matrix"][output] + except KeyError as e: + control_command_log.error( + f"Ausgang konnte für die Aktion {action.config.name} nicht zugeordnet werden.") + raise KeyError(f"Ausgang konnte für die Aktion {action.config.name} nicht zugeordnet werden. " + "Bitte prüfen Sie die Konfiguration.") from e for io in data.data.system_data.values(): if isinstance(io, AbstractIoDevice): modules_threads.append( diff --git a/packages/helpermodules/broker.py b/packages/helpermodules/broker.py index c9b6940c6f..ecd5d2d30c 100644 --- a/packages/helpermodules/broker.py +++ b/packages/helpermodules/broker.py @@ -35,9 +35,9 @@ def __init__(self, def start_infinite_loop(self) -> None: self.client.loop_forever() - def start_finite_loop(self) -> None: + def start_finite_loop(self, timeout: float = 1) -> None: self.client.loop_start() - time.sleep(1) + time.sleep(timeout) self.client.loop_stop() def disconnect(self) -> None: diff --git a/packages/helpermodules/command.py b/packages/helpermodules/command.py index be0cb0c5f9..11ee114bf1 100644 --- a/packages/helpermodules/command.py +++ b/packages/helpermodules/command.py @@ -1203,7 +1203,7 @@ def addConsumer(self, connection_id: str, payload: dict) -> None: self._publish_default_metadata_fields(f"openWB/set/consumer/{new_id}", ConsumerGet()) self._publish_default_metadata_fields(f"openWB/set/consumer/{new_id}", ConsumerSet()) Pub().pub(f"openWB/set/consumer/{new_id}/extra_meter", None) - Pub().pub(f"openWB/set/consumer/{new_id}/usage", asdict(Usage())) + Pub().pub(f"openWB/set/consumer/{new_id}/usage", asdict(Usage(type=consumer_default["usage"][0]))) self.max_id_hierarchy = new_id Pub().pub("openWB/set/command/max_id/hierarchy", new_id) pub_user_message( diff --git a/packages/helpermodules/command_test.py b/packages/helpermodules/command_test.py index d9115dfbd4..d01f479802 100644 --- a/packages/helpermodules/command_test.py +++ b/packages/helpermodules/command_test.py @@ -1,10 +1,13 @@ from unittest.mock import Mock +from types import SimpleNamespace import pytest from control.chargepoint.chargepoint import Chargepoint from control.chargepoint.chargepoint_state_update import ChargepointStateUpdate +from control.consumer.usage import ConsumerUsage import dataclass_utils +from helpermodules import command from helpermodules.command import Command from helpermodules.measurement_logging import process_log from helpermodules.subdata import SubData @@ -74,3 +77,33 @@ def test_getDailyLog(regular_daily_log_entry, assert len(mock_pub.method_calls) == 1 assert mock_pub.method_calls[0][1][0] == 'openWB/set/log/daily/20250616' assert mock_pub.method_calls[0][1][1] == regular_daily_log_entry_processed_legacy_converted + + +@pytest.mark.parametrize("usage_type", [ConsumerUsage.SUSPENDABLE_TUNABLE, ConsumerUsage.METER_ONLY]) +def test_add_consumer_uses_module_default_usage_type(usage_type, mock_pub, monkeypatch): + consumer_setup = SimpleNamespace( + id=0, + type="test_consumer", + name="Test-Verbraucher", + info={}, + configuration=SimpleNamespace(), + vendor="test", + usage=(usage_type,), + ) + descriptor = SimpleNamespace(configuration_factory=Mock(return_value=consumer_setup)) + monkeypatch.setattr(command.importlib, "import_module", Mock(return_value=SimpleNamespace( + device_descriptor=descriptor))) + monkeypatch.setattr(command, "pub_user_message", Mock()) + monkeypatch.setattr(SubData, "counter_all_data", Mock()) + SubData.counter_all_data.get_id_evu_counter.return_value = 0 + SubData.counter_all_data.data.get.hierarchy = [] + SubData.counter_all_data.data.get.loadmanagement_prios = [] + command_instance = Command.__new__(Command) + command_instance.max_id_hierarchy = 0 + + command_instance.addConsumer("test", {"data": {"vendor": "test", "type": "test_consumer"}}) + + usage_calls = [call for call in mock_pub.pub.call_args_list + if call.args[0] == "openWB/set/consumer/1/usage"] + assert len(usage_calls) == 1 + assert usage_calls[0].args[1]["type"] == usage_type.value diff --git a/packages/helpermodules/create_debug.py b/packages/helpermodules/create_debug.py index 88e1b95450..6c2db31f0c 100644 --- a/packages/helpermodules/create_debug.py +++ b/packages/helpermodules/create_debug.py @@ -131,6 +131,9 @@ def config_and_state(): if secondary is False: with ErrorHandlingContext(): parsed_data += f"\n## Hierarchy ##\n{get_hierarchy(data.data.counter_all_data.data.get.hierarchy)}\n" + with ErrorHandlingContext(): + parsed_data += (f"\n## Priorities ##\n" + f"{get_priorities(data.data.counter_all_data.data.get.loadmanagement_prios)}\n") with ErrorHandlingContext(): if secondary: @@ -237,6 +240,13 @@ def get_hierarchy(hierarchy, level=0): parsed_data += f"{element['type']}: {cp.name} (ID: {element['id']})\n" except Exception: parsed_data += f"{element['type']} (ID: {element['id']})\n" + elif element["type"] == "consumer": + try: + consumer = data.data.consumer_data[f"consumer{element['id']}"].data.module + parsed_data += (f"{element['type']}: {consumer.name} " + f"(ID: {element['id']}, consumer_type: {consumer.type})\n") + except Exception: + parsed_data += f"{element['type']} (ID: {element['id']})\n" else: try: for key, value in data.data.system_data.items(): @@ -268,6 +278,28 @@ def get_hierarchy(hierarchy, level=0): return parsed_data +def get_priorities(priorities, level=0): + # get friendly names of elements + priority = 1 + parsed_data = "" + for element in priorities: + parsed_data += "-" * (level * 2) + "| " + if level == 0: + parsed_data += f"Prio: {priority}, " + priority += 1 + if element["type"] == "group": + parsed_data += f"{element['type']}: {element.get('label', 'unnamed')}\n" + elif element["type"] == "vehicle": + vehicle = data.data.ev_data[f"ev{element['id']}"].data + parsed_data += f"{element['type']}: {vehicle.name} (ID: {element['id']})\n" + elif element["type"] == "consumer": + consumer = data.data.consumer_data[f"consumer{element['id']}"].data.module + parsed_data += f"{element['type']}: {consumer.name} (ID: {element['id']}, consumer_type: {consumer.type})\n" + if "children" in element and element["children"]: + parsed_data += get_priorities(element["children"], level + 1) + return parsed_data + + def get_parsed_cp_data(cp: Chargepoint) -> str: parsed_data = "" with ErrorHandlingContext(): @@ -426,11 +458,11 @@ def write_to_file(file_handler, func, default: Optional[Any] = None): write_to_file(df, lambda: header) write_to_file(df, lambda: f'# section: system #\n{get_common_data()}' f'Kernel: {run_shell_command("uname -s -r -v -m -o")}' - 'Model: ' - f'{run_shell_command("cat /proc/device-tree/model 2>/dev/null||echo unknown")}\n' - f'CID:\n{run_shell_command("mmc cid read /sys/block/mmcblk0/device")}\n' f'Uptime:{run_command(["uptime"])}{run_command(["free"])}\n') - write_to_file(df, lambda: f'# section: hardware #\n{get_hardware_data()}') + write_to_file(df, lambda: '# section: hardware #\nModel: ' + f'{run_shell_command("cat /proc/device-tree/model 2>/dev/null||echo unknown")}\n' + f'CID:\n{run_shell_command("mmc cid read /sys/block/mmcblk0/device")}') + write_to_file(df, lambda: f'{get_hardware_data()}') write_to_file(df, lambda: f'USB_Devices:\n{run_shell_command(["lsusb"])}\n') write_to_file(df, lambda: f"# section: configuration and state #\n{config_and_state()}") write_to_file(df, lambda: f"# section: errors #\n{filter_log_file('main', 'ERROR', 30)}\n") diff --git a/packages/helpermodules/data_migration/data_migration.py b/packages/helpermodules/data_migration/data_migration.py index 40da4697bc..510448599d 100644 --- a/packages/helpermodules/data_migration/data_migration.py +++ b/packages/helpermodules/data_migration/data_migration.py @@ -25,7 +25,8 @@ from helpermodules.data_migration.id_mapping import MapId from helpermodules.hardware_configuration import update_hardware_configuration from helpermodules.measurement_logging.process_log import get_totals -from helpermodules.measurement_logging.write_log import LegacySmartHomeLogData, get_names +from helpermodules.measurement_logging.process_log_entry_builder import LegacySmartHomeLogData +from helpermodules.measurement_logging.write_log import get_names from helpermodules.timecheck import convert_timedelta_to_time_string, get_difference from helpermodules.utils import joined_thread_handler from helpermodules.utils.precision_math import string_to_float, string_to_int diff --git a/packages/helpermodules/exceptions/registry.py b/packages/helpermodules/exceptions/registry.py index b83987142c..ed745ecae2 100644 --- a/packages/helpermodules/exceptions/registry.py +++ b/packages/helpermodules/exceptions/registry.py @@ -25,7 +25,8 @@ class ExceptionRegistry: def translate_exception(self, exception: Exception) -> Tuple[str, FaultStateLevel]: entry = self.find_registry_entry(exception) if entry is None: - return str(exception.args[0]), FaultStateLevel.ERROR + message = (str(exception.args[0]) if exception.args else str(exception)) or exception.__class__.__name__ + return message, FaultStateLevel.ERROR if isinstance(entry.handler, str): return entry.handler, FaultStateLevel.ERROR result = entry.handler(exception) diff --git a/packages/helpermodules/exceptions/registry_test.py b/packages/helpermodules/exceptions/registry_test.py index 7d3d90d258..a39897184f 100644 --- a/packages/helpermodules/exceptions/registry_test.py +++ b/packages/helpermodules/exceptions/registry_test.py @@ -31,6 +31,11 @@ class ErrorF(ErrorD): pass +class EmptyStringException(Exception): + def __str__(self): + return "" + + @pytest.mark.parametrize("exception,expected_message", [ [ErrorRoot, "ErrorRoot"], [ErrorB, "B"], @@ -68,3 +73,21 @@ def test_accepts_all_supported_formats(handler): # evaluation assert isinstance(actual[0], str) assert actual[0] == "msg" + + +@pytest.mark.parametrize("exception,expected_message", [ + pytest.param(Exception(), "Exception", id="no args fallback"), + pytest.param(EmptyStringException(), "EmptyStringException", id="empty string fallback"), +]) +def test_translate_exception_falls_back_to_class_name_for_empty_message(exception: Exception, + expected_message: str, + monkeypatch): + # setup + monkeypatch.setattr(ExceptionRegistry, "registry", []) + registry = ExceptionRegistry() + + # execution + actual = registry.translate_exception(exception) + + # evaluation + assert actual[0] == expected_message diff --git a/packages/helpermodules/logger.py b/packages/helpermodules/logger.py index d3c62246de..73fdf53719 100644 --- a/packages/helpermodules/logger.py +++ b/packages/helpermodules/logger.py @@ -229,6 +229,33 @@ def combine_logs(base_path: str, name: str): f.write(logs) +# The totals subprocess needs its own log file, including messages from process_log. +# In the main process, process_log must keep propagating to main log. +def setup_generate_totals_logging(include_process_log: bool = False) -> None: + generate_totals_log = logging.getLogger("generate_totals") + generate_totals_log.setLevel(logging.DEBUG) + generate_totals_log.propagate = False + RAMDISK_PATH.mkdir(parents=True, exist_ok=True) + handler = next( + (handler for handler in generate_totals_log.handlers + if isinstance(handler, RotatingFileHandler) + and handler.baseFilename == str(RAMDISK_PATH / 'generate_totals.log')), + None, + ) + if handler is None: + handler = RotatingFileHandler(RAMDISK_PATH / 'generate_totals.log', maxBytes=5 * 1000000, backupCount=1) + handler.setFormatter(logging.Formatter(FORMAT_STR_SHORT)) + handler.addFilter(RedactingFilter()) + generate_totals_log.addHandler(handler) + + if include_process_log: + process_log = logging.getLogger("helpermodules.measurement_logging.process_log") + process_log.setLevel(logging.DEBUG) + process_log.propagate = False + if handler not in process_log.handlers: + process_log.addHandler(handler) + + def setup_logging() -> None: def mb_to_bytes(megabytes: int) -> int: return megabytes * 1000000 @@ -274,6 +301,9 @@ def mb_to_bytes(megabytes: int) -> int: mqtt_file_handler.addFilter(RedactingFilter()) mqtt_log.addHandler(mqtt_file_handler) + # Totals generation logger + setup_generate_totals_logging() + # Steuve control command logger steuve_control_command_log = logging.getLogger("steuve_control_command") steuve_control_command_log.propagate = False diff --git a/packages/helpermodules/measurement_logging/generate_totals_subprocess.py b/packages/helpermodules/measurement_logging/generate_totals_subprocess.py new file mode 100644 index 0000000000..7f722ae55a --- /dev/null +++ b/packages/helpermodules/measurement_logging/generate_totals_subprocess.py @@ -0,0 +1,93 @@ +import fcntl +import json +from pathlib import Path +from datetime import date + +from typing import List +from helpermodules import logger, pub +from helpermodules.measurement_logging.process_log import (save_daily_source_totals, + save_monthly_source_totals) + +import logging + + +BASE_PATH = Path(__file__).resolve().parents[3] +logger.setup_generate_totals_logging(include_process_log=True) +log = logging.getLogger("generate_totals") + +LOCK_FILE = BASE_PATH / "data" / "generate_totals.lock" + + +def get_all_days_to_calc(): + try: + daily_log_dir = BASE_PATH / "data" / "daily_log" + if not daily_log_dir.is_dir(): + return None + + today_stem = date.today().strftime("%Y%m%d") + daily_log_files = sorted( + [p for p in daily_log_dir.glob("*.json") + if p.stem.isdigit() and p.stem < today_stem], + key=lambda p: p.stem, + ) + + log.debug(f"Anzahl zu berechnender Tageslogs: {len(daily_log_files)}") + return daily_log_files + except Exception: + log.debug("Fehler beim Abrufen der Tageslogs. Es werden keine Tageslogs berechnet.") + return None + + +def get_all_months_to_calc(days: List[str] = None): + if days is None: + return None + current_month = date.today().strftime("%Y%m") + months = sorted({p.stem[:6] for p in days if len(p.stem) == 8 and p.stem[:6] < current_month}) + log.debug(f"Anzahl zu berechnender Monatslogs: {len(months)}") + return months + + +def _generate_totals(): + + errors = 0 + days_to_calc = get_all_days_to_calc() or [] + months_to_calc = get_all_months_to_calc(days_to_calc) or [] + + for day in days_to_calc: + try: + save_daily_source_totals(day.stem, saving=True) + except Exception: + log.exception(f"Fehler beim Generieren der Tageserträge für {day.stem}, Tag wird übersprungen.") + errors += 1 + continue + + for month in months_to_calc: + try: + save_monthly_source_totals(month, saving=True) + except Exception: + log.exception(f"Fehler beim Generieren der Monatswerte für {month}, Monat wird übersprungen.") + errors += 1 + continue + + log.info(f"Totals-Migration abgeschlossen. Fehlerhafte Logs: {errors}.") + publisher = pub.Pub() + publish_info = publisher.publisher.client.publish( + "openWB/set/system/log_data_ready", json.dumps(True), qos=1, retain=True) + publish_info.wait_for_publish() + + +def generate_totals(): + LOCK_FILE.parent.mkdir(parents=True, exist_ok=True) + + with LOCK_FILE.open("w") as lock_file: + try: + fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) + except Exception: + log.exception("generate_totals_subprocess läuft bereits. " + "Der zweite Prozess wird beendet.") + return + _generate_totals() + + +if __name__ == "__main__": + generate_totals() diff --git a/packages/helpermodules/measurement_logging/generate_totals_subprocess_test.py b/packages/helpermodules/measurement_logging/generate_totals_subprocess_test.py new file mode 100644 index 0000000000..81d03cb521 --- /dev/null +++ b/packages/helpermodules/measurement_logging/generate_totals_subprocess_test.py @@ -0,0 +1,43 @@ +from unittest.mock import Mock + +from helpermodules.measurement_logging import generate_totals_subprocess + + +def test_generate_totals_calls_generator_when_lock_is_acquired(monkeypatch, tmp_path): + # setup + lock_file = tmp_path / "locks" / "generate_totals.lock" + monkeypatch.setattr(generate_totals_subprocess, "LOCK_FILE", lock_file) + + flock_mock = Mock() + generate_mock = Mock() + + monkeypatch.setattr(generate_totals_subprocess.fcntl, "flock", flock_mock) + monkeypatch.setattr(generate_totals_subprocess, "_generate_totals", generate_mock) + + # execution + generate_totals_subprocess.generate_totals() + + # evaluation + flock_mock.assert_called_once() + generate_mock.assert_called_once() + assert lock_file.parent.is_dir() + + +def test_generate_totals_skips_generator_when_lock_is_not_acquired(monkeypatch, tmp_path): + # setup + lock_file = tmp_path / "locks" / "generate_totals.lock" + monkeypatch.setattr(generate_totals_subprocess, "LOCK_FILE", lock_file) + + flock_mock = Mock(side_effect=RuntimeError("already locked")) + generate_mock = Mock() + + monkeypatch.setattr(generate_totals_subprocess.fcntl, "flock", flock_mock) + monkeypatch.setattr(generate_totals_subprocess, "_generate_totals", generate_mock) + + # execution + generate_totals_subprocess.generate_totals() + + # evaluation + flock_mock.assert_called_once() + generate_mock.assert_not_called() + assert lock_file.parent.is_dir() diff --git a/packages/helpermodules/measurement_logging/process_log.py b/packages/helpermodules/measurement_logging/process_log.py index 39bec7cf5d..8df895de7f 100644 --- a/packages/helpermodules/measurement_logging/process_log.py +++ b/packages/helpermodules/measurement_logging/process_log.py @@ -1,27 +1,28 @@ -from enum import Enum + +from copy import deepcopy import json import logging from pathlib import Path -from typing import Dict, List, Optional, Tuple, Union - +from typing import Dict, List, Optional +from datetime import datetime from helpermodules import timecheck -from helpermodules.measurement_logging.write_log import (LegacySmartHomeLogData, LogType, create_entry, - get_previous_entry) -from helpermodules.messaging import MessageType, pub_system_message -from helpermodules.utils.precision_math import decimal_add, decimal_divide, decimal_multiply, decimal_subtract +from helpermodules.utils.precision_math import decimal_divide +from helpermodules.measurement_logging.process_log_calculation import (CalculationType, + FILE_ERRORS, + analyse_percentage_totals, + analyse_percentage, + get_totals, + _analyse_energy_source, + _process_entries) +from helpermodules.measurement_logging.write_log import ( + LegacySmartHomeLogData, + create_entry, + get_previous_entry +) log = logging.getLogger(__name__) -class CalculationType(Enum): - ALL = 0 - POWER = 1 - ENERGY = 2 - - -FILE_ERRORS = (FileNotFoundError, json.decoder.JSONDecodeError) - - def get_default_charge_log_columns() -> Dict: return { "time_begin": True, @@ -184,52 +185,6 @@ def convert_legacy_units(data: dict) -> dict: return data -def safe_get_nested(data: Dict, *keys, default: Union[int, float] = 0) -> Union[int, float]: - current = data - for key in keys: - if isinstance(current, dict) and key in current: - current = current[key] - else: - return default - return current if isinstance(current, (int, float)) else default - - -def get_totals(entries: List, process_entries: bool = True) -> Dict: - """ Berechnet aus der übergebenen Liste "entries" die Summen (totals). - "process_entries" besagt, ob die Differenzen der einzelnen Einträge noch - berechnet werden müssen. - """ - if process_entries: - entries = _process_entries(entries, CalculationType.ENERGY) - totals = {"consumer": {}, "cp": {}, "counter": {}, "pv": {}, "bat": {}, "sh": {}, "hc": {}} - for totals_group in totals.keys(): - for entry in entries: - if totals_group in entry: - for entry_module in entry[totals_group]: - try: - if entry_module not in totals[totals_group]: - if totals_group == "hc": - totals[totals_group][entry_module] = {"energy_imported": 0.0} - elif totals_group == "pv": - totals[totals_group][entry_module] = {"energy_exported": 0.0} - else: - totals[totals_group][entry_module] = {"energy_imported": 0.0, "energy_exported": 0.0} - if totals_group == "counter" and "grid" in entry[totals_group][entry_module]: - totals[totals_group][entry_module]["grid"] = entry[ - totals_group][entry_module]["grid"] - for entry_module_key, entry_module_value in entry[totals_group][entry_module].items(): - if "grid" != entry_module_key and entry_module_key in totals[totals_group][entry_module]: - # avoid floating point issues with using Decimal - current_total = totals[totals_group][entry_module][entry_module_key] - totals[totals_group][entry_module][entry_module_key] = decimal_add( - current_total, entry_module_value) # totals in Wh! - - except Exception: - log.exception(f"Fehler beim Berechnen der Summe von {entry_module}; " - f"group:{totals_group}, module:{entry_module}, key:{entry_module_key}") - return totals - - def get_daily_log(date: str): data = _collect_daily_log_data(date) data["entries"] = _process_entries(data["entries"], CalculationType.ALL) @@ -245,8 +200,8 @@ def _collect_daily_log_data(date: str): log_data = json.load(json_file) if date == timecheck.create_timestamp_YYYYMMDD(): # beim aktuellen Tag den aktuellen Datensatz ergänzen - log_data["entries"].append(create_entry( - LogType.DAILY, LegacySmartHomeLogData(), get_previous_entry(parent_file, log_data))) + log_data["entries"].append(create_entry(LegacySmartHomeLogData(), + get_previous_entry(parent_file, log_data))) else: # bei älteren als letzten Datensatz den des nächsten Tags try: @@ -263,492 +218,305 @@ def _collect_daily_log_data(date: str): def get_monthly_log(date: str): - data = _collect_monthly_log_data(date) - data["entries"] = _process_entries(data["entries"], CalculationType.ENERGY) - data["totals"] = get_totals(data["entries"], False) - data = _analyse_energy_source(data) - return data + + if not (len(date) == 6 and date.isdigit()): + log.debug(f"Ungültiges Datum für Monats-Summen: {date}") + return {"entries": [], "names": {}, "colors": {}, "totals": {}} + # Nur Logs ab dem ältesten Tageslog auswerten + # Sonst werden unnötige Totals-Werte gespeichert + oldest_log_day = _oldest_log_day() + if (oldest_log_day is None + or date < oldest_log_day[:6]): # Jahr und Monat + return {"entries": [], "names": {}, "colors": {}, "totals": {}} + + monthly_entries = [] + monthly_names = {} + monthly_colors = {} + + today = timecheck.create_timestamp_YYYYMMDD() + day = f"{date}01" + + while day.startswith(date): + # Zukunftstage/-monate nicht verarbeiten, um keine leeren daily_totals zu erzeugen. + if day > today: + break + if day < oldest_log_day: + day = timecheck.get_relative_date_string(day, day_offset=1) + continue + + content = load_daily_source_totals_content(day) + if content is None: + # Dürfte eigentlich nie passieren... + content = save_daily_source_totals(day, saving=(day != today)) + + if isinstance(content, dict): + daily_totals = content.get("totals") + daily_entry = content.get("entry") + + if isinstance(daily_totals, dict) and isinstance(daily_entry, dict) and len(daily_entry) > 0: + daily_entry = deepcopy(daily_entry) + daily_entry["date"] = day + _apply_source_totals(daily_entry, daily_totals) + + monthly_entries.append(daily_entry) + if isinstance(content.get("names"), dict): + monthly_names.update(content["names"]) + if isinstance(content.get("colors"), dict): + monthly_colors.update(content["colors"]) + + day = timecheck.get_relative_date_string(day, day_offset=1) + + if len(monthly_entries) > 0: + data = {"entries": monthly_entries, "names": monthly_names, "colors": monthly_colors} + data["totals"] = get_totals(data["entries"], False) + data["totals"] = analyse_percentage_totals(data["entries"], data["totals"]) + return data + + # Fallback, wenn keine Daten vorhanden sind + return {"entries": [], "names": {}, "colors": {}, "totals": {}} + + +def get_yearly_log(year: str): + # Nur Logs ab dem ältesten Tageslog auswerten + # Sonst werden unnötige totals Werte gespeichert + oldest_log_day = _oldest_log_day() + if oldest_log_day is None or year < oldest_log_day[:4]: + return {"entries": [], "names": {}, "colors": {}, "totals": {}} + + monthly_entries = [] + monthly_names = {} + monthly_colors = {} + this_month = timecheck.create_timestamp_YYYYMM() + month = f"{year}01" + + while month.startswith(year): + if month > this_month: + break + + if month < oldest_log_day[:6]: + month = timecheck.get_relative_date_string(month, month_offset=1) + continue + + content = load_monthly_source_totals_content(month) + if content is None: + # Dürfte eigentlich nie passieren... + content = save_monthly_source_totals(month, None, saving=(month != this_month)) + + if isinstance(content, dict): + monthly_totals = content.get("totals") + monthly_entry = content.get("entry") + + if isinstance(monthly_totals, dict) and isinstance(monthly_entry, dict) and len(monthly_entry) > 0: + monthly_entry = deepcopy(monthly_entry) + monthly_entry["date"] = month + _apply_source_totals(monthly_entry, monthly_totals) + + monthly_entries.append(monthly_entry) + if isinstance(content.get("names"), dict): + monthly_names.update(content["names"]) + if isinstance(content.get("colors"), dict): + monthly_colors.update(content["colors"]) + month = timecheck.get_relative_date_string(month, month_offset=1) + + if len(monthly_entries) > 0: + data = {"entries": monthly_entries, "names": monthly_names, "colors": monthly_colors} + data["totals"] = get_totals(data["entries"], False) + data["totals"] = analyse_percentage_totals(data["entries"], data["totals"]) + return data + + # Fallback, wenn keine Daten vorhanden sind + return {"entries": [], "names": {}, "colors": {}, "totals": {}} + + +def _get_data_folder_path() -> str: + return str(Path(__file__).resolve().parents[3] / "data") -def _collect_monthly_log_data(date: str): +def save_daily_source_totals(date: str, saving: bool = True): try: - with open(f"{_get_data_folder_path()}/monthly_log/{date}.json", "r") as jsonFile: - log_data = json.load(jsonFile) - this_month = timecheck.create_timestamp_YYYYMM() - if date == this_month: - # add last entry of current day, if current month is requested - try: - today = timecheck.create_timestamp_YYYYMMDD() - with open(f"{_get_data_folder_path()}/daily_log/{today}.json", - "r") as todayJsonFile: - today_log_data = json.load(todayJsonFile) - if len(today_log_data["entries"]) > 0: - log_data["entries"].append(today_log_data["entries"][-1]) - except FILE_ERRORS: - pass - else: - # add first entry of next month - try: - next_date = timecheck.get_relative_date_string(date, month_offset=1) - with open(f"{_get_data_folder_path()}/monthly_log/{next_date}.json", - "r") as nextJsonFile: - next_log_data = json.load(nextJsonFile) - log_data["entries"].append(next_log_data["entries"][0]) - except FILE_ERRORS: - pass + if not (len(date) == 8 and date.isdigit()): + log.debug(f"Ungültiges Datum für Tages-Summen: {date}") + return None + data = _collect_daily_log_data(date) + source_entries = data.get("entries", []) + + # Keep one source snapshot for the daily output entry and process entries in place to avoid full-copy peaks. + source_daily_entry = _get_last_entry_for_period(source_entries, date, "%Y%m%d") + processed_entries = _process_entries(source_entries, calculation=CalculationType.ENERGY) + + totals = get_totals(processed_entries, process_entries=False) + analysed_data = _analyse_energy_source({ + "entries": processed_entries, + "totals": totals, + "names": data.get("names", {}) + }) + totals = analysed_data["totals"] + + daily_entry = {} + if source_daily_entry is not None: + daily_entry = deepcopy(source_daily_entry) + daily_entry["date"] = date + _apply_source_totals(daily_entry, totals) + + # Erzeugt Ordner daily_totals, falls nicht vorhanden + totals_dir = Path(_get_data_folder_path()) / "daily_totals" + filepath = totals_dir / f"{date}_totals.json" + + content = { + "date": date, + "totals": totals, + "entry": daily_entry, + "names": data.get("names", {}), + "colors": data.get("colors", {}) + } + + if saving: + totals_dir.mkdir(parents=True, exist_ok=True) + with open(str(filepath), "w") as jsonFile: + json.dump(content, jsonFile, ensure_ascii=False, indent=2) + + log.debug(f"Tages-Summen für {date} gespeichert in {filepath}") + + return content + except FILE_ERRORS: - log_data = {"entries": [], "names": {}} - return log_data + log.exception(f"Fehler beim Speichern der Tages-Summen für {date}") -def get_yearly_log(year: str): - data = _collect_yearly_log_data(year) - data["entries"] = _process_entries(data["entries"], CalculationType.ENERGY) - data["totals"] = get_totals(data["entries"], False) - data = _analyse_energy_source(data) - return data +def load_daily_source_totals_content(date: str): + try: + if not (len(date) == 8 and date.isdigit()): + log.debug(f"Ungültiges Datum für Tages-Summen: {date}") + return None + filepath = f"{_get_data_folder_path()}/daily_totals/{date}_totals.json" + if not Path(filepath).is_file(): + log.debug(f"Keine Tages-Summen-Datei gefunden: {filepath}") + return None -def _collect_yearly_log_data(year: str): - def add_monthly_log(month: str, check_next_month: bool = False) -> None: - monthly_log_path = Path(__file__).resolve().parents[3]/"data"/"monthly_log" - try: - with open(monthly_log_path / f"{month}.json", "r") as jsonFile: - content = json.load(jsonFile) - entries.append(content["entries"][0]) - # add last entry of current file if next file is missing - if check_next_month: - next_month = timecheck.get_relative_date_string(month, month_offset=1) - if not (monthly_log_path / (next_month+".json")).is_file(): - entries.append(content["entries"][-1]) - log.debug(f"Keine Logdatei für Monat {next_month} gefunden, " - f"füge letzten Datensatz von {month} ein: {entries[-1]['date']}") - names.update(content["names"]) - colors.update(content["colors"]) - except FILE_ERRORS: - log.debug(f"Kein Log für Monat {month} gefunden.") - - def add_daily_log(day: str) -> None: - try: - with open(f"{_get_data_folder_path()}/daily_log/{day}.json", "r") as dayJsonFile: - day_log_data = json.load(dayJsonFile) - if len(day_log_data["entries"]) > 0: - entries.append(day_log_data["entries"][-1]) - except FILE_ERRORS: - pass - - entries = [] - names = {} - colors = {} - dates = [] - - # we have to find a valid data range - this_year = timecheck.create_timestamp_YYYY() - this_month = timecheck.create_timestamp_YYYYMM() - if year < this_year: - # if the requested year is in the past, just add all possible months - for month in range(1, 13): - dates.append(f"{year}{month:02}") - else: - # add all months until current month - for month in range(1, int(this_month[-2:])+1): - dates.append(f"{year}{month:02}") - # add data for month range - for date in dates: - try: - log.debug(f"add regular month: {date}") - add_monthly_log(date, date != this_month) - except Exception: - log.exception(f"Fehler beim Zusammenstellen der Jahresdaten für Monat {date}") - - # now we have to find a valid "next" entry for proper calculation - if year == this_year: # current year - # add todays last entry - this_day = timecheck.create_timestamp_YYYYMMDD() - try: - log.debug(f"add today: {this_day}") - add_daily_log(this_day) - except Exception: - log.exception(f"Fehler beim Zusammenstellen der Jahresdaten für den aktuellen Tag {this_day}") - else: - # no special handling here, just add first entry of next month - next_date = f"{int(year)+1}01" - try: - log.debug(f"add next month: {next_date}") - add_monthly_log(next_date) - except Exception: - log.exception(f"Fehler beim Zusammenstellen der Jahresdaten für Monat {next_date}") - - # return our data - return {"entries": entries, "names": names, "colors": colors} - - -def _analyse_energy_source(data, calc_cp: Optional[str] = None) -> Dict: - if data and len(data["entries"]) > 0: - try: - if data.get("message") is None: - data["message"] = "" - for i in range(0, len(data["entries"])): - data["entries"][i], message_analyse = analyse_percentage(data["entries"][i]) - data["entries"][i], message_calc = calc_energy_imported_by_source( - data["entries"][i], data["names"], message_key_filter=calc_cp) - data["message"] += message_analyse + message_calc - data["totals"] = analyse_percentage_totals(data["entries"], data["totals"]) - except Exception: - log.exception("Fehler beim Analysieren der Energiequellen") - pub_system_message({}, "Fehler beim Berechnen des Strom-Mix", MessageType.ERROR) - data["message"] = "Fehler beim Berechnen des Strom-Mix." - return data + with open(str(filepath), "r") as jsonFile: + content = json.load(jsonFile) + log.debug(f"Tages-Summen für {date} geladen aus {filepath}") + return content -def analyse_percentage(entry) -> Tuple[Dict, str]: - EOOR_STATE_MSG = ("Der Strom-Mix um " + entry["date"] + - " konnte nicht berechnet werden, da sich {} im Fehlerzustand befindet. Alle Verbräuche werden" + - " dem Netz zugerechnet.\n") - - def format(value): - return round(value, 4) - - def get_valid_energy(section: str, value: str) -> Union[int, float]: - """Liefert den Energiewert ohne fehlerhafte Einzelmodule.""" - modules = entry.get(section, {}) - if (len(modules) <= 1 or - safe_get_nested(entry, section, "all", "fault_state") != 2): - return safe_get_nested(entry, section, "all", value) - return sum( - module_data.get(value, 0) - for module_name, module_data in modules.items() - if module_name != "all" and module_data.get("fault_state", 0) != 2 - ) - - def get_grid_counter(entry) -> Dict: - # es gibt nur einen Zähler am EVU-Punkt - for counter in entry["counter"].values(): - if counter.get("grid") is True: - return counter - else: - raise KeyError(f"Kein Zähler für das Netz gefunden in Eintrag '{entry['timestamp']}'.") - try: - message = "" - grid_counter = get_grid_counter(entry) - # nur wenn der Grid-Zähler im Feherlzustand ist - # ins Fallback gehen - if (grid_counter.get("fault_state", None) == 2): - entry["energy_source"] = {"grid": 1, "pv": 0, "bat": 0, "cp": 0} - message += EOOR_STATE_MSG.format("der Zähler für das Netz") - else: - if safe_get_nested(entry, "bat", "all", "fault_state") == 2 and len(entry.get("bat", {})) > 1: - message += f"Der Strom-Mix um {entry['date'] } wird trotz Fehlerzustand " \ - "mindestens eines Speichers aus den vorhandenen Messwerten berechnet. \n" - if safe_get_nested(entry, "cp", "all", "fault_state") == 2 and len(entry.get("cp", {})) > 1: - message += f"Der Strom-Mix um {entry['date'] } wird trotz Fehlerzustand " \ - "mindestens eines Ladepunkts aus den vorhandenen Messwerten berechnet. \n" - if safe_get_nested(entry, "pv", "all", "fault_state") == 2 and len(entry.get("pv", {})) > 1: - message += f"Der Strom-Mix um {entry['date'] } wird trotz Fehlerzustand " \ - "mindestens eines Wechselrichters aus den vorhandenen Messwerten berechnet. \n" - - bat_imported = get_valid_energy("bat", "energy_imported") - bat_exported = get_valid_energy("bat", "energy_exported") - cp_exported = get_valid_energy("cp", "energy_exported") - pv_exported = get_valid_energy("pv", "energy_exported") - grid_imported = grid_counter.get("energy_imported", 0) - grid_exported = grid_counter.get("energy_exported", 0) - consumption = grid_imported - grid_exported + pv_exported + bat_exported - bat_imported + cp_exported - if consumption < 0: - consumption = 0 - - try: - # Berechnung der Energiequellenanteile: - # Da die genaue Aufteilung der Energiequellen nicht bekannt ist, - # wird die Einspeisung (grid_exported) entsprechend der folgenden Priorität aufgeteilt: - # 1. PV - # 2. Batterie - # 3. CP - # Sollte die Einspeisung nicht komplett von PV gedeckt werden, - # wird der Rest von der Batterie übernommen, und falls nötig, vom CP. - # - # Entsprechend ähnlich wird der Batterieimport nach folgender Priorität aufgeteilt: - # 1. PV - # 2. Grid - # 3. CP - # - # Anschließend wird der Verbrauch (ohne Einspeisung und Batterieimport) auf energy_source aufgeteilt. - if consumption <= 0: - entry["energy_source"] = {"grid": 0, "pv": 0, "bat": 0, "cp": 0} - else: - - direct = {"grid": grid_imported, "pv": pv_exported, "bat": bat_exported, "cp": cp_exported} - - # Einspeißung aufteilen - unassigned_export = grid_exported - for source in ("pv", "bat", "cp"): - if direct[source] > unassigned_export: - direct[source] -= unassigned_export - unassigned_export = 0 - break - else: - unassigned_export -= direct[source] - direct[source] = 0 - - if unassigned_export > 0: - # Fehler / inkonsistente Energiebilanz - log.warning( - f"grid_exported konnte nicht vollständig verteilt werden. " - f"Unverteilter Anteil: {unassigned_export}" - ) - - # Batterieimport aufteilen - unassigned_bat_import = bat_imported - for source in ("pv", "grid", "cp"): - if direct[source] > unassigned_bat_import: - direct[source] -= unassigned_bat_import - unassigned_bat_import = 0 - break - else: - unassigned_bat_import -= direct[source] - direct[source] = 0 - - if unassigned_bat_import > 0: - # Fehler / inkonsistente Energiebilanz - log.warning( - f"bat_imported konnte nicht vollständig verteilt werden. " - f"Unverteilter Anteil: {unassigned_bat_import}" - ) - - # Anschließend Verbrauch aufteilen, wenn vorhanden - direct_total = sum(direct.values()) - - if direct_total <= 0: - entry["energy_source"] = {"grid": 0, "pv": 0, "bat": 0, "cp": 0} - else: - entry["energy_source"] = { - "grid": format(direct["grid"] / direct_total), - "pv": format(direct["pv"] / direct_total), - "bat": format(direct["bat"] / direct_total), - "cp": format(direct["cp"] / direct_total)} - except ZeroDivisionError: - entry["energy_source"] = {"grid": 0, "pv": 0, "bat": 0, "cp": 0} - except Exception: - log.exception(f"Fehler beim Berechnen des Strom-Mix von {entry['timestamp']}") - message += f"Fehler beim Berechnen des Strom-Mix von {entry['timestamp']}.\n" - finally: - return entry, message + except FILE_ERRORS: + log.exception(f"Fehler beim Laden der Tages-Summen für {date}") -ERROR_STATE_MESSAGE = ("Die Anteile der Energiequellen für {} konnten nicht berechnet werden, da er sich im " + - "Fehlerzustand befindet. Die Verbräuche werden mit 0 kWh angesetzt.\n") +def save_monthly_source_totals(date: str, data: Optional[Dict] = None, saving: bool = True): + try: + # Hauptsächlich für Midnight-Handler + # Wenn keine Daten übergeben werden, dann die Monatswerte berechnen + if data is None: + data = get_monthly_log(date) + + totals = data["totals"] + source_entries = data.get("entries", []) + monthly_entry = {} + source_monthly_entry = _get_last_entry_for_period(source_entries, date, "%Y%m") + if source_monthly_entry is not None: + # Nur den letzten Eintrag des Monats nehmen + monthly_entry = deepcopy(source_monthly_entry) + monthly_entry["date"] = date + + # Erzeugt Ordner monthly_totals, falls nicht vorhanden + totals_dir = Path(_get_data_folder_path()) / "monthly_totals" + filepath = totals_dir / f"{date}_totals.json" + content = { + "date": date, + "totals": totals, + "entry": monthly_entry, + "names": data.get("names", {}), + "colors": data.get("colors", {}) + } + + if saving: + totals_dir.mkdir(parents=True, exist_ok=True) + with open(str(filepath), "w") as jsonFile: + json.dump(content, jsonFile, ensure_ascii=False, indent=2) + + log.debug(f"Monats-Summen für {date} gespeichert in {filepath}") + return content + + except FILE_ERRORS: + log.exception(f"Fehler beim Speichern der Monats-Summen für {date}") -def calc_energy_imported_by_source(entry, names, message_key_filter: Optional[str] = None) -> Tuple[Dict, str]: +def load_monthly_source_totals_content(date: str): try: - message = "" - - if "energy_source" in entry.keys(): - energy_source = entry["energy_source"] - hc_section = entry.get("hc") - if isinstance(hc_section, dict) and "all" in hc_section: - hc_all = hc_section["all"] - if isinstance(hc_all, dict): - if hc_all.get("fault_state", 0) != 2 and "energy_imported" in hc_all: - for source in ("grid", "pv", "bat", "cp"): - hc_all[f"energy_imported_{source}"] = decimal_multiply( - hc_all["energy_imported"], energy_source[source]) - else: - for source in ("grid", "pv", "bat", "cp"): - hc_all[f"energy_imported_{source}"] = 0 - if message_key_filter is None or message_key_filter == "hc": - message += ERROR_STATE_MESSAGE.format("den Hausverbrauch") - - cp_section = entry.get("cp") - if isinstance(cp_section, dict): - for cp_key, cp_data in cp_section.items(): - if isinstance(cp_data, dict): - if cp_data.get("fault_state", 0) != 2 and "energy_imported" in cp_data: - for source in ("grid", "pv", "bat", "cp"): - cp_data[f"energy_imported_{source}"] = decimal_multiply( - cp_data["energy_imported"], energy_source[source]) - else: - for source in ("grid", "pv", "bat", "cp"): - cp_data[f"energy_imported_{source}"] = 0 - if message_key_filter is None or message_key_filter == cp_key: - message += ERROR_STATE_MESSAGE.format(f"Ladepunkt {names.get(cp_key, cp_key)}") - - consumer_section = entry.get("consumer") - if isinstance(consumer_section, dict): - for consumer_key, consumer_data in consumer_section.items(): - if isinstance(consumer_data, dict): - if consumer_data.get("fault_state", 0) != 2 and "energy_imported" in consumer_data: - for source in ("grid", "pv", "bat", "cp"): - consumer_data[f"energy_imported_{source}"] = decimal_multiply( - consumer_data["energy_imported"], energy_source[source]) - else: - for source in ("grid", "pv", "bat", "cp"): - consumer_data[f"energy_imported_{source}"] = 0 - if message_key_filter is None or message_key_filter == consumer_key: - message += ERROR_STATE_MESSAGE.format( - f"Verbraucher {names.get(consumer_key, consumer_key)}") - - counter_section = entry.get("counter") - if isinstance(counter_section, dict): - for counter_key, counter_data in counter_section.items(): - if isinstance(counter_data, dict) and counter_data.get("grid") is False: - if counter_data.get("fault_state", 0) != 2 and "energy_imported" in counter_data: - for source in ("grid", "pv", "bat", "cp"): - counter_data[f"energy_imported_{source}"] = decimal_multiply( - counter_data["energy_imported"], energy_source[source]) - else: - for source in ("grid", "pv", "bat", "cp"): - counter_data[f"energy_imported_{source}"] = 0 - if message_key_filter is None or message_key_filter == counter_key: - message += ERROR_STATE_MESSAGE.format(f"Zähler {names.get(counter_key, counter_key)}") - except Exception: - log.exception(f"Fehler beim Berechnen der Energie-Anteile aus dem Strom-Mix von {entry['timestamp']}") - message += f"Fehler beim Berechnen des Strom-Mix von {entry['timestamp']}.\n" - finally: - return entry, message - - -def analyse_percentage_totals(entries, totals): - for section in ("consumer", "cp", "hc"): - if "all" not in totals[section].keys(): - totals[section]["all"] = {} - for source in ("grid", "pv", "bat", "cp"): - totals["hc"]["all"].update({f"energy_imported_{source}": 0}) - for entry in entries: - if "hc" in entry.keys() and "all" in entry["hc"].keys(): - current_value = totals["hc"]["all"][f"energy_imported_{source}"] - add_value = entry["hc"]["all"].get(f"energy_imported_{source}", 0) - totals["hc"]["all"][f"energy_imported_{source}"] = decimal_add( - current_value, add_value) - for key in entry["cp"].keys(): - if f"energy_imported_{source}" in entry["cp"][key].keys(): - if totals["cp"][key].get(f"energy_imported_{source}") is None: - totals["cp"][key].update({f"energy_imported_{source}": 0}) - current_value = totals["cp"][key][f"energy_imported_{source}"] - add_value = entry["cp"][key][f"energy_imported_{source}"] - totals["cp"][key][f"energy_imported_{source}"] = decimal_add( - current_value, add_value) - for key in entry["consumer"].keys(): - if f"energy_imported_{source}" in entry["consumer"][key].keys(): - if totals["consumer"][key].get(f"energy_imported_{source}") is None: - totals["consumer"][key].update({f"energy_imported_{source}": 0}) - current_value = totals["consumer"][key][f"energy_imported_{source}"] - add_value = entry["consumer"][key][f"energy_imported_{source}"] - totals["consumer"][key][f"energy_imported_{source}"] = decimal_add( - current_value, add_value) - for key, counter in entry["counter"].items(): - if counter["grid"] is False and f"energy_imported_{source}" in counter: - if totals["counter"][key].get(f"energy_imported_{source}") is None: - totals["counter"][key].update({f"energy_imported_{source}": 0}) - current_value = totals["counter"][key][f"energy_imported_{source}"] - add_value = counter[f"energy_imported_{source}"] - totals["counter"][key][f"energy_imported_{source}"] = decimal_add( - current_value, add_value) - return totals - - -def _process_entries(entries: List, calculation: CalculationType): - if entries: - if len(entries) == 1: - # Wenn es nur einen Eintrag gibt, kann keine Differenz berechnet werden und die Werte sind 0. - entry = entries[0] - for type in ("bat", "consumer", "counter", "cp", "pv", "sh", "hc"): - if type in entry: - for module in entry[type].keys(): - if calculation in [CalculationType.POWER, CalculationType.ALL]: - entry[type][module].update({ - "power_average": 0, - "power_imported": 0, - "power_exported": 0 - }) - if calculation in [CalculationType.ENERGY, CalculationType.ALL]: - entry[type][module].update({ - "energy_imported": 0, - "energy_exported": 0 - }) - elif len(entries) > 1: - for i in range(0, len(entries)-1): - entry = entries[i] - next_entry = entries[i+1] - entries[i] = process_entry(entry, next_entry, calculation) - entries.pop() - return entries - - -def process_entry(entry: dict, next_entry: dict, calculation: CalculationType): - time_diff = next_entry["timestamp"] - entry["timestamp"] - for type in ("bat", "consumer", "counter", "cp", "pv", "sh", "hc"): - if type in entry: - for module in entry[type].keys(): - try: - new_data = {} - if "imported" in entry[type][module].keys() or "exported" in entry[type][module].keys(): - def get_current_and_next(value_key: str) -> Tuple[float, float]: - def get_single_value(source: dict, default: int = 0) -> float: - try: - return source[type][module][value_key] - except KeyError: - return default - current_value = get_single_value(entry) - return current_value, get_single_value(next_entry, current_value) - value_imported, next_value_imported = get_current_and_next("imported") - value_exported, next_value_exported = get_current_and_next("exported") - if calculation in [CalculationType.POWER, CalculationType.ALL]: - if next_value_imported < value_imported or next_value_exported < value_exported: - # do not calculate as we have a backwards jump in our meter value! - average_power = 0 - else: - average_power = _calculate_average_power( - time_diff, value_imported, next_value_imported, - value_exported, next_value_exported) - new_data.update({ - "power_average": average_power, - "power_imported": average_power if average_power >= 0 else 0, - "power_exported": average_power * -1 if average_power < 0 else 0 - }) - if calculation in [CalculationType.ENERGY, CalculationType.ALL]: - if next_value_imported < value_imported: - # do not calculate as we have a backwards jump in our meter value! - energy_imported = 0 - else: - energy_imported = decimal_subtract(next_value_imported, - value_imported) - if next_value_exported < value_exported: - # do not calculate as we have a backwards jump in our meter value! - energy_exported = 0 - else: - energy_exported = decimal_subtract(next_value_exported, - value_exported) - new_data.update({ - "energy_imported": energy_imported, - "energy_exported": energy_exported - }) - entry[type][module].update(new_data) - except Exception: - log.exception("Fehler beim Berechnen der Leistung") - # next_entry may contain new modules, we add them here - try: - for module, module_data in next_entry[type].items(): - if module not in entry[type].keys(): - log.debug(f"adding module {module} from next entry") - if calculation in [CalculationType.POWER, CalculationType.ALL]: - module_data.update({"power_average": 0, "power_imported": 0, "power_exported": 0}) - if calculation in [CalculationType.ENERGY, CalculationType.ALL]: - module_data.update({"energy_imported": 0, "energy_exported": 0}) - entry[type].update({module: module_data}) - except KeyError: - # catch missing "type" - pass + filepath = f"{_get_data_folder_path()}/monthly_totals/{date}_totals.json" + if not Path(filepath).is_file(): + log.debug(f"Keine Monats-Summen-Datei gefunden: {filepath}") + return None + + with open(str(filepath), "r") as jsonFile: + content = json.load(jsonFile) + + log.debug(f"Monats-Summen für {date} geladen aus {filepath}") + return content + + except FILE_ERRORS: + log.exception(f"Fehler beim Laden der Monats-Summen für {date}") + + +def _get_last_entry_for_period(entries: List, period: str, period_format: str) -> Optional[Dict]: + # Suche den letzten Eintrag in der Liste, der dem angegebenen Zeitraum entspricht. + for entry in reversed(entries): + if isinstance(entry, dict) and isinstance(entry.get("timestamp"), (int, float)): + entry_period = datetime.fromtimestamp(entry["timestamp"]).strftime(period_format) + if entry_period == period: + return entry + + # Fallback: Falls kein passender Zeitstempel gefunden wird, letzten gueltigen Eintrag verwenden. + for entry in reversed(entries): + if isinstance(entry, dict): + return entry + return None + + +def _apply_source_totals(entry: Dict, daily_totals: Dict): + for section, section_totals in daily_totals.items(): + section_data = entry.get(section) + if not isinstance(section_totals, dict): + continue + + if not isinstance(section_data, dict): + section_data = {} + entry[section] = section_data + + for module, module_totals in section_totals.items(): + module_data = section_data.get(module) + if not isinstance(module_totals, dict): + continue + + if not isinstance(module_data, dict): + module_data = {} + section_data[module] = module_data + + # Alle vorhandenen Summenfelder des Moduls mit den aggregierten Summen ueberschreiben. + module_data.update(module_totals) + # Der kopierte Eintrag enthält ggf. noch den energy_source des letzten + # Intervalls/Tages. Nach dem Einspielen der aggregierten Energiewerte + # muss der Strom-Mix deshlab ebenfalls neu berechnet werden. + entry.pop("energy_source", None) + entry, _ = analyse_percentage(entry) + return entry -def _calculate_average_power(time_diff: float, current_imported: float = 0, next_imported: float = 0, - current_exported: float = 0, next_exported: float = 0) -> float: - imported_diff = decimal_subtract(next_imported, current_imported) - exported_diff = decimal_subtract(next_exported, current_exported) - energy_diff = decimal_subtract(imported_diff, exported_diff) - return decimal_multiply(energy_diff, 3600 / time_diff) # Ws -> W +def _oldest_log_day() -> Optional[str]: + try: + daily_log_dir = Path(_get_data_folder_path()) / "daily_log" + if not daily_log_dir.is_dir(): + return None + daily_log_files = [p for p in daily_log_dir.glob("*.json") if p.stem.isdigit()] + if not daily_log_files: + return None -def _get_data_folder_path() -> str: - return str(Path(__file__).resolve().parents[3] / "data") + oldest_file = min(daily_log_files, key=lambda f: f.stem) + return oldest_file.stem + except Exception: + log.exception("Fehler beim Ermitteln des ältesten Tageslogs") + return None diff --git a/packages/helpermodules/measurement_logging/process_log_calculation.py b/packages/helpermodules/measurement_logging/process_log_calculation.py new file mode 100644 index 0000000000..71652d4e8e --- /dev/null +++ b/packages/helpermodules/measurement_logging/process_log_calculation.py @@ -0,0 +1,471 @@ +from enum import Enum +from typing import Dict, List, Optional, Tuple, Union +import json +import logging +from helpermodules.utils.precision_math import (decimal_add, + decimal_subtract, + decimal_multiply) + +from helpermodules.messaging import MessageType, pub_system_message + +log = logging.getLogger(__name__) + + +class CalculationType(Enum): + ALL = 0 + POWER = 1 + ENERGY = 2 + + +FILE_ERRORS = (FileNotFoundError, json.decoder.JSONDecodeError) + + +def safe_get_nested(data: Dict, *keys, default: Union[int, float] = 0) -> Union[int, float]: + current = data + for key in keys: + if isinstance(current, dict) and key in current: + current = current[key] + else: + return default + return current if isinstance(current, (int, float)) else default + + +def get_totals(entries: List, process_entries: bool = True) -> Dict: + """ Berechnet aus der übergebenen Liste "entries" die Summen (totals). + "process_entries" besagt, ob die Differenzen der einzelnen Einträge noch + berechnet werden müssen. + """ + if process_entries: + entries = _process_entries(entries, CalculationType.ENERGY) + totals = {"consumer": {}, "cp": {}, "counter": {}, "pv": {}, "bat": {}, "sh": {}, "hc": {}} + for totals_group in totals.keys(): + for entry in entries: + if totals_group in entry: + for entry_module in entry[totals_group]: + try: + if entry_module not in totals[totals_group]: + if totals_group == "hc": + totals[totals_group][entry_module] = {"energy_imported": 0.0} + elif totals_group == "pv": + totals[totals_group][entry_module] = {"energy_exported": 0.0} + else: + totals[totals_group][entry_module] = {"energy_imported": 0.0, "energy_exported": 0.0} + if totals_group == "counter" and "grid" in entry[totals_group][entry_module]: + totals[totals_group][entry_module]["grid"] = entry[ + totals_group][entry_module]["grid"] + for entry_module_key, entry_module_value in entry[totals_group][entry_module].items(): + if "grid" != entry_module_key and entry_module_key in totals[totals_group][entry_module]: + # avoid floating point issues with using Decimal + current_total = totals[totals_group][entry_module][entry_module_key] + totals[totals_group][entry_module][entry_module_key] = decimal_add( + current_total, entry_module_value) # totals in Wh! + + except Exception: + log.exception(f"Fehler beim Berechnen der Summe von {entry_module}; " + f"group:{totals_group}, module:{entry_module}, key:{entry_module_key}") + return totals + + +def _process_entries(entries: List, calculation: CalculationType): + if entries: + if len(entries) == 1: + # Wenn es nur einen Eintrag gibt, kann keine Differenz berechnet werden und die Werte sind 0. + entry = entries[0] + for type in ("bat", "consumer", "counter", "cp", "pv", "sh", "hc"): + if type in entry: + for module in entry[type].keys(): + if calculation in [CalculationType.POWER, CalculationType.ALL]: + entry[type][module].update({ + "power_average": 0, + "power_imported": 0, + "power_exported": 0 + }) + if calculation in [CalculationType.ENERGY, CalculationType.ALL]: + entry[type][module].update({ + "energy_imported": 0, + "energy_exported": 0 + }) + elif len(entries) > 1: + for i in range(0, len(entries)-1): + entry = entries[i] + next_entry = entries[i+1] + entries[i] = process_entry(entry, next_entry, calculation) + entries.pop() + return entries + + +def process_entry(entry: dict, next_entry: dict, calculation: CalculationType): + time_diff = next_entry["timestamp"] - entry["timestamp"] + for type in ("bat", "consumer", "counter", "cp", "pv", "sh", "hc"): + if type in entry: + for module in entry[type].keys(): + try: + new_data = {} + if "imported" in entry[type][module].keys() or "exported" in entry[type][module].keys(): + def get_current_and_next(value_key: str) -> Tuple[float, float]: + def get_single_value(source: dict) -> Optional[float]: + try: + value = source[type][module][value_key] + if isinstance(value, (int, float)): + return float(value) + except KeyError: + pass + return None + + current_value = get_single_value(entry) + next_value = get_single_value(next_entry) + + # Keep meter deltas neutral if one side is invalid/missing. + if current_value is None and next_value is None: + return 0.0, 0.0 + if current_value is None: + return next_value, next_value + if next_value is None: + return current_value, current_value + return current_value, next_value + value_imported, next_value_imported = get_current_and_next("imported") + value_exported, next_value_exported = get_current_and_next("exported") + if calculation in [CalculationType.POWER, CalculationType.ALL]: + if next_value_imported < value_imported or next_value_exported < value_exported: + # do not calculate as we have a backwards jump in our meter value! + average_power = 0 + else: + average_power = _calculate_average_power( + time_diff, value_imported, next_value_imported, + value_exported, next_value_exported) + new_data.update({ + "power_average": average_power, + "power_imported": average_power if average_power >= 0 else 0, + "power_exported": average_power * -1 if average_power < 0 else 0 + }) + if calculation in [CalculationType.ENERGY, CalculationType.ALL]: + if next_value_imported < value_imported: + # do not calculate as we have a backwards jump in our meter value! + energy_imported = 0 + else: + energy_imported = decimal_subtract(next_value_imported, + value_imported) + if next_value_exported < value_exported: + # do not calculate as we have a backwards jump in our meter value! + energy_exported = 0 + else: + energy_exported = decimal_subtract(next_value_exported, + value_exported) + new_data.update({ + "energy_imported": energy_imported, + "energy_exported": energy_exported + }) + entry[type][module].update(new_data) + except Exception: + log.exception("Fehler beim Berechnen der Leistung") + # next_entry may contain new modules, we add them here + try: + for module, module_data in next_entry[type].items(): + if module not in entry[type].keys(): + log.debug(f"adding module {module} from next entry") + if calculation in [CalculationType.POWER, CalculationType.ALL]: + module_data.update({"power_average": 0, "power_imported": 0, "power_exported": 0}) + if calculation in [CalculationType.ENERGY, CalculationType.ALL]: + module_data.update({"energy_imported": 0, "energy_exported": 0}) + entry[type].update({module: module_data}) + except KeyError: + # catch missing "type" + pass + return entry + + +def _calculate_average_power(time_diff: float, current_imported: float = 0, next_imported: float = 0, + current_exported: float = 0, next_exported: float = 0) -> float: + imported_diff = decimal_subtract(next_imported, current_imported) + exported_diff = decimal_subtract(next_exported, current_exported) + energy_diff = decimal_subtract(imported_diff, exported_diff) + return decimal_multiply(energy_diff, 3600 / time_diff) # Ws -> W + + +def analyse_percentage(entry) -> Tuple[Dict, str]: + EOOR_STATE_MSG = ("Der Strom-Mix um " + entry["date"] + + " konnte nicht berechnet werden, da sich {} im Fehlerzustand befindet. Alle Verbräuche werden" + + " dem Netz zugerechnet.\n") + + def format(value): + return round(value, 4) + + def get_valid_energy(section: str, value: str) -> Union[int, float]: + """Liefert den Energiewert ohne fehlerhafte Einzelmodule.""" + modules = entry.get(section, {}) + if (len(modules) <= 1 or + safe_get_nested(entry, section, "all", "fault_state") != 2): + return safe_get_nested(entry, section, "all", value) + return sum( + module_data.get(value, 0) + for module_name, module_data in modules.items() + if module_name != "all" and module_data.get("fault_state", 0) != 2 + ) + + def get_grid_counter(entry) -> Dict: + # es gibt nur einen Zähler am EVU-Punkt + for counter in entry["counter"].values(): + if counter.get("grid") is True: + return counter + else: + raise KeyError(f"Kein Zähler für das Netz gefunden in Eintrag '{entry['timestamp']}'.") + try: + message = "" + grid_counter = get_grid_counter(entry) + # nur wenn der Grid-Zähler im Feherlzustand ist + # ins Fallback gehen + if (grid_counter.get("fault_state", None) == 2): + entry["energy_source"] = {"grid": 1, "pv": 0, "bat": 0, "cp": 0} + message += EOOR_STATE_MSG.format("der Zähler für das Netz") + else: + if safe_get_nested(entry, "bat", "all", "fault_state") == 2 and len(entry.get("bat", {})) > 1: + message += f"Der Strom-Mix um {entry['date'] } wird trotz Fehlerzustand " \ + "mindestens eines Speichers aus den vorhandenen Messwerten berechnet. \n" + if safe_get_nested(entry, "cp", "all", "fault_state") == 2 and len(entry.get("cp", {})) > 1: + message += f"Der Strom-Mix um {entry['date'] } wird trotz Fehlerzustand " \ + "mindestens eines Ladepunkts aus den vorhandenen Messwerten berechnet. \n" + if safe_get_nested(entry, "pv", "all", "fault_state") == 2 and len(entry.get("pv", {})) > 1: + message += f"Der Strom-Mix um {entry['date'] } wird trotz Fehlerzustand " \ + "mindestens eines Wechselrichters aus den vorhandenen Messwerten berechnet. \n" + + bat_imported = get_valid_energy("bat", "energy_imported") + bat_exported = get_valid_energy("bat", "energy_exported") + cp_exported = get_valid_energy("cp", "energy_exported") + pv_exported = get_valid_energy("pv", "energy_exported") + grid_imported = grid_counter.get("energy_imported", 0) + grid_exported = grid_counter.get("energy_exported", 0) + consumption = grid_imported - grid_exported + pv_exported + bat_exported - bat_imported + cp_exported + if consumption < 0: + consumption = 0 + + try: + # Berechnung der Energiequellenanteile: + # Da die genaue Aufteilung der Energiequellen nicht bekannt ist, + # wird die Einspeisung (grid_exported) entsprechend der folgenden Priorität aufgeteilt: + # 1. PV + # 2. Batterie + # 3. CP + # Sollte die Einspeisung nicht komplett von PV gedeckt werden, + # wird der Rest von der Batterie übernommen, und falls nötig, vom CP. + # + # Entsprechend ähnlich wird der Batterieimport nach folgender Priorität aufgeteilt: + # 1. PV + # 2. Grid + # 3. CP + # + # Anschließend wird der Verbrauch (ohne Einspeisung und Batterieimport) auf energy_source aufgeteilt. + if consumption <= 0: + entry["energy_source"] = {"grid": 0, "pv": 0, "bat": 0, "cp": 0} + else: + + direct = {"grid": grid_imported, "pv": pv_exported, "bat": bat_exported, "cp": cp_exported} + + # Einspeißung aufteilen + unassigned_export = grid_exported + for source in ("pv", "bat", "cp"): + if direct[source] > unassigned_export: + direct[source] -= unassigned_export + unassigned_export = 0 + break + else: + unassigned_export -= direct[source] + direct[source] = 0 + + if unassigned_export > 0: + # Fehler / inkonsistente Energiebilanz + log.warning( + f"grid_exported konnte nicht vollständig verteilt werden. " + f"Unverteilter Anteil: {unassigned_export}" + ) + + # Batterieimport aufteilen + unassigned_bat_import = bat_imported + for source in ("pv", "grid", "cp"): + if direct[source] > unassigned_bat_import: + direct[source] -= unassigned_bat_import + unassigned_bat_import = 0 + break + else: + unassigned_bat_import -= direct[source] + direct[source] = 0 + + if unassigned_bat_import > 0: + # Fehler / inkonsistente Energiebilanz + log.warning( + f"bat_imported konnte nicht vollständig verteilt werden. " + f"Unverteilter Anteil: {unassigned_bat_import}" + ) + + # Anschließend Verbrauch aufteilen, wenn vorhanden + direct_total = sum(direct.values()) + + if direct_total <= 0: + entry["energy_source"] = {"grid": 0, "pv": 0, "bat": 0, "cp": 0} + else: + entry["energy_source"] = { + "grid": format(direct["grid"] / direct_total), + "pv": format(direct["pv"] / direct_total), + "bat": format(direct["bat"] / direct_total), + "cp": format(direct["cp"] / direct_total)} + except ZeroDivisionError: + entry["energy_source"] = {"grid": 0, "pv": 0, "bat": 0, "cp": 0} + except Exception: + log.exception(f"Fehler beim Berechnen des Strom-Mix von {entry['timestamp']}") + message += f"Fehler beim Berechnen des Strom-Mix von {entry['timestamp']}.\n" + finally: + return entry, message + + +ERROR_STATE_MESSAGE = ("Die Anteile der Energiequellen für {} konnten nicht berechnet werden, da er sich im " + + "Fehlerzustand befindet. Die Verbräuche werden mit 0 kWh angesetzt.\n") + + +def calc_energy_imported_by_source(entry, names, message_key_filter: Optional[str] = None) -> Tuple[Dict, str]: + try: + message = "" + + if "energy_source" in entry.keys(): + energy_source = entry["energy_source"] + hc_section = entry.get("hc") + if isinstance(hc_section, dict) and "all" in hc_section: + hc_all = hc_section["all"] + if isinstance(hc_all, dict): + if hc_all.get("fault_state", 0) != 2 and "energy_imported" in hc_all: + for source in ("grid", "pv", "bat", "cp"): + hc_all[f"energy_imported_{source}"] = decimal_multiply( + hc_all["energy_imported"], energy_source[source]) + else: + for source in ("grid", "pv", "bat", "cp"): + hc_all[f"energy_imported_{source}"] = 0 + if message_key_filter is None or message_key_filter == "hc": + message += ERROR_STATE_MESSAGE.format("den Hausverbrauch") + + cp_section = entry.get("cp") + if isinstance(cp_section, dict): + for cp_key, cp_data in cp_section.items(): + if isinstance(cp_data, dict): + if cp_data.get("fault_state", 0) != 2 and "energy_imported" in cp_data: + for source in ("grid", "pv", "bat", "cp"): + cp_data[f"energy_imported_{source}"] = decimal_multiply( + cp_data["energy_imported"], energy_source[source]) + else: + for source in ("grid", "pv", "bat", "cp"): + cp_data[f"energy_imported_{source}"] = 0 + if message_key_filter is None or message_key_filter == cp_key: + message += ERROR_STATE_MESSAGE.format(f"Ladepunkt {names.get(cp_key, cp_key)}") + + consumer_section = entry.get("consumer") + if isinstance(consumer_section, dict): + for consumer_key, consumer_data in consumer_section.items(): + if isinstance(consumer_data, dict): + if consumer_data.get("fault_state", 0) != 2 and "energy_imported" in consumer_data: + for source in ("grid", "pv", "bat", "cp"): + consumer_data[f"energy_imported_{source}"] = decimal_multiply( + consumer_data["energy_imported"], energy_source[source]) + else: + for source in ("grid", "pv", "bat", "cp"): + consumer_data[f"energy_imported_{source}"] = 0 + if message_key_filter is None or message_key_filter == consumer_key: + message += ERROR_STATE_MESSAGE.format( + f"Verbraucher {names.get(consumer_key, consumer_key)}") + + counter_section = entry.get("counter") + if isinstance(counter_section, dict): + for counter_key, counter_data in counter_section.items(): + if isinstance(counter_data, dict) and counter_data.get("grid") is False: + if counter_data.get("fault_state", 0) != 2 and "energy_imported" in counter_data: + for source in ("grid", "pv", "bat", "cp"): + counter_data[f"energy_imported_{source}"] = decimal_multiply( + counter_data["energy_imported"], energy_source[source]) + else: + for source in ("grid", "pv", "bat", "cp"): + counter_data[f"energy_imported_{source}"] = 0 + if message_key_filter is None or message_key_filter == counter_key: + message += ERROR_STATE_MESSAGE.format(f"Zähler {names.get(counter_key, counter_key)}") + except Exception: + log.exception(f"Fehler beim Berechnen der Energie-Anteile aus dem Strom-Mix von {entry['timestamp']}") + message += f"Fehler beim Berechnen des Strom-Mix von {entry['timestamp']}.\n" + finally: + return entry, message + + +def analyse_percentage_totals(entries, totals): + sources = ("grid", "pv", "bat", "cp") + + def ensure_zero_source_keys(module_totals: Dict): + if isinstance(module_totals, dict): + for source in sources: + module_totals[f"energy_imported_{source}"] = 0 + try: + for section in ("consumer", "cp", "hc"): + if "all" not in totals[section].keys(): + totals[section]["all"] = {} + for source in sources: + totals["hc"]["all"].update({f"energy_imported_{source}": 0}) + for entry in entries: + if "hc" in entry.keys() and "all" in entry["hc"].keys(): + current_value = totals["hc"]["all"][f"energy_imported_{source}"] + add_value = entry["hc"]["all"].get(f"energy_imported_{source}", 0) + totals["hc"]["all"][f"energy_imported_{source}"] = decimal_add( + current_value, add_value) + for key in entry["cp"].keys(): + if f"energy_imported_{source}" in entry["cp"][key].keys(): + if totals["cp"][key].get(f"energy_imported_{source}") is None: + totals["cp"][key].update({f"energy_imported_{source}": 0}) + current_value = totals["cp"][key][f"energy_imported_{source}"] + add_value = entry["cp"][key][f"energy_imported_{source}"] + totals["cp"][key][f"energy_imported_{source}"] = decimal_add( + current_value, add_value) + for key in entry["consumer"].keys(): + if f"energy_imported_{source}" in entry["consumer"][key].keys(): + if totals["consumer"][key].get(f"energy_imported_{source}") is None: + totals["consumer"][key].update({f"energy_imported_{source}": 0}) + current_value = totals["consumer"][key][f"energy_imported_{source}"] + add_value = entry["consumer"][key][f"energy_imported_{source}"] + totals["consumer"][key][f"energy_imported_{source}"] = decimal_add( + current_value, add_value) + for key, counter in entry["counter"].items(): + if counter["grid"] is False and f"energy_imported_{source}" in counter: + if totals["counter"][key].get(f"energy_imported_{source}") is None: + totals["counter"][key].update({f"energy_imported_{source}": 0}) + current_value = totals["counter"][key][f"energy_imported_{source}"] + add_value = counter[f"energy_imported_{source}"] + totals["counter"][key][f"energy_imported_{source}"] = decimal_add( + current_value, add_value) + except Exception: + log.exception("Fehler beim Berechnen der Summen der Energiequellen") + # Im Fehlerfall werden die Totals auf 0 gesetzt + # -> dann wird nur die Ladung/Entladung-Leistung angezeigt. + # -> sprich keine Aufteilung der Energiequellen, sondern nur die Gesamtwerte. + totals.setdefault("hc", {}) + totals["hc"].setdefault("all", {}) + ensure_zero_source_keys(totals["hc"]["all"]) + + totals.setdefault("cp", {}) + for key, module_totals in totals["cp"].items(): + ensure_zero_source_keys(module_totals) + + totals.setdefault("counter", {}) + for key, module_totals in totals["counter"].items(): + ensure_zero_source_keys(module_totals) + + return totals + return totals + + +def _analyse_energy_source(data, calc_cp: Optional[str] = None) -> Dict: + if data and len(data["entries"]) > 0: + try: + if data.get("message") is None: + data["message"] = "" + for i in range(0, len(data["entries"])): + data["entries"][i], message_analyse = analyse_percentage(data["entries"][i]) + data["entries"][i], message_calc = calc_energy_imported_by_source( + data["entries"][i], data["names"], message_key_filter=calc_cp) + data["message"] += message_analyse + message_calc + data["totals"] = analyse_percentage_totals(data["entries"], data["totals"]) + except Exception: + log.exception("Fehler beim Analysieren der Energiequellen") + pub_system_message({}, "Fehler beim Berechnen des Strom-Mix", MessageType.ERROR) + data["message"] = "Fehler beim Berechnen des Strom-Mix." + return data diff --git a/packages/helpermodules/measurement_logging/process_log_calculation_test.py b/packages/helpermodules/measurement_logging/process_log_calculation_test.py new file mode 100644 index 0000000000..dcaf0f5181 --- /dev/null +++ b/packages/helpermodules/measurement_logging/process_log_calculation_test.py @@ -0,0 +1,439 @@ +from copy import deepcopy +from typing import Dict +import pytest + +from helpermodules.measurement_logging.process_log_calculation import ( + analyse_percentage, + _calculate_average_power, + process_entry, + calc_energy_imported_by_source, + CalculationType) + + +@pytest.mark.parametrize("name", + ["regular", + "negative consumption", + "cp export"]) +def test_analyse_percentage(name: str, + daily_log_entry_percentage: Dict, + daily_log_entry_percentage_negative_consumption: Dict, + daily_log_entry_percentage_cp_discharge: Dict): + # setup + if name == "regular": + data = daily_log_entry_percentage + expected = deepcopy(data) + expected.update({"energy_source": {'bat': 0.2398, 'cp': 0.0, 'grid': 0.6504, 'pv': 0.1099}}) + elif name == "negative consumption": + data = daily_log_entry_percentage_negative_consumption + expected = deepcopy(data) + expected.update({"energy_source": {'bat': 0.0, 'cp': 0.0, 'grid': 0.0, 'pv': 0.0}}) + elif name == "cp export": + data = daily_log_entry_percentage_cp_discharge + expected = deepcopy(data) + expected.update({"energy_source": {'bat': 0.2006, 'cp': 0.1459, 'grid': 0.5441, 'pv': 0.1094}}) + + # execution + entry, message = analyse_percentage(data) + + # evaluation + assert entry == expected + assert message == "" + + +def test_analyse_percentage_ignores_faulty_modules(): + entry = { + "timestamp": 1234567890, + "date": "00:31", + "bat": { + "all": {"energy_imported": 0, "energy_exported": 120, "fault_state": 2}, + "bat1": {"energy_imported": 0, "energy_exported": 20, "fault_state": 0}, + "bat2": {"energy_imported": 0, "energy_exported": 100, "fault_state": 2}, + }, + "cp": { + "all": {"energy_exported": 80, "fault_state": 2}, + "cp1": {"energy_exported": 30, "fault_state": 0}, + "cp2": {"energy_exported": 50, "fault_state": 2}, + }, + "pv": { + "all": {"energy_exported": 100, "fault_state": 2}, + "pv1": {"energy_exported": 40, "fault_state": 0}, + "pv2": {"energy_exported": 60, "fault_state": 2}, + }, + "counter": { + "counter0": { + "grid": True, + "energy_imported": 100, + "energy_exported": 0, + "fault_state": 0, + } + }, + } + + result, message = analyse_percentage(entry) + + assert result["energy_source"] == { + "grid": 0.5263, + "pv": 0.2105, + "bat": 0.1053, + "cp": 0.1579, + } + assert message == ( + "Der Strom-Mix um 00:31 wird trotz Fehlerzustand mindestens eines Speichers aus den vorhandenen Messwerten " + "berechnet. \n" + "Der Strom-Mix um 00:31 wird trotz Fehlerzustand mindestens eines Ladepunkts aus den vorhandenen Messwerten " + "berechnet. \n" + "Der Strom-Mix um 00:31 wird trotz Fehlerzustand mindestens eines Wechselrichters aus den vorhandenen " + "Messwerten berechnet. \n" + ) + + +@pytest.mark.parametrize("test_case, entry_data, expected_energy_source, should_be_unchanged", [ + ( + "zero_consumption", + { + "timestamp": 1234567890, + "date": "00:31", + "bat": {"all": {"energy_imported": 5.0, "energy_exported": 5.0, "fault_state": 0}}, + "cp": {"all": {"energy_exported": 0.0, "fault_state": 0}}, + "pv": {"all": {"energy_exported": 0.0, "fault_state": 0}}, + "counter": {"counter0": {"grid": True, "energy_imported": 5.0, "energy_exported": 5.0, "fault_state": 0}} + }, + {"grid": 0, "pv": 0, "bat": 0, "cp": 0}, + False + ), + ( + "missing_sections", + { + "timestamp": 1234567890, + "date": "00:31", + "counter": {"counter0": {"grid": True, "energy_imported": 10.0, "energy_exported": 2.0, "fault_state": 0}} + }, + {"grid": 1.0, "pv": 0.0, "bat": 0.0, "cp": 0.0}, + False + ), + ( + "no_grid_counter", + { + "timestamp": 1234567890, + "date": "00:31", + "bat": {"all": {"energy_imported": 0.0, "energy_exported": 5.0, "fault_state": 0}}, + "counter": {"counter0": {"grid": False, "energy_imported": 10.0, "energy_exported": 2.0, "fault_state": 0}} + }, + None, + True + ) +]) +def test_analyse_percentage_edge_cases(test_case, entry_data, expected_energy_source, should_be_unchanged): + # execution + result, message = analyse_percentage(entry_data) + + # evaluation + if should_be_unchanged: + # Entry should be unchanged, no energy_source added due to error + assert result["timestamp"] == entry_data["timestamp"] + assert "energy_source" not in result or result.get("energy_source") is None + else: + # Energy source should be calculated correctly + assert result["energy_source"] == expected_energy_source + assert message == "" + + +def test_calculate_average_power(): + # setup and execution + power = _calculate_average_power(100, 250, 300) + + # evaluation + assert power == 1800 + + +def test_calc_energy_imported_by_source(): + # setup + entry = { + "timestamp": 1234567890, + "energy_source": {"grid": 0.6523, "pv": 0.2487, "bat": 0.0789, "cp": 0.0201}, + "hc": {"all": {"energy_imported": 2345.6, "fault_state": 0}}, + "cp": { + "cp1": {"energy_imported": 15723.4, "fault_state": 0}, + "cp2": {"energy_imported": 22108.7, "fault_state": 0} + }, + "counter": { + "counter0": {"grid": True, "energy_imported": 45892.3, "fault_state": 0}, + "counter1": {"grid": False, "energy_imported": 8956.7, "fault_state": 0} + } + } + + # execution + result, message = calc_energy_imported_by_source(entry, {}) + + # evaluation - realistic Wh values with decimal precision + assert result["hc"]["all"]["energy_imported_grid"] == 1530.035 + assert result["hc"]["all"]["energy_imported_pv"] == 583.351 + assert result["hc"]["all"]["energy_imported_bat"] == 185.068 + assert result["hc"]["all"]["energy_imported_cp"] == 47.147 + + assert result["cp"]["cp1"]["energy_imported_grid"] == 10256.374 + assert result["cp"]["cp1"]["energy_imported_pv"] == 3910.41 + assert result["cp"]["cp1"]["energy_imported_bat"] == 1240.576 + assert result["cp"]["cp1"]["energy_imported_cp"] == 316.04 + + assert result["cp"]["cp2"]["energy_imported_grid"] == 14421.505 + assert result["cp"]["cp2"]["energy_imported_pv"] == 5498.434 + assert result["cp"]["cp2"]["energy_imported_bat"] == 1744.376 + assert result["cp"]["cp2"]["energy_imported_cp"] == 444.385 + + assert result["counter"]["counter1"]["energy_imported_grid"] == 5842.455 + assert result["counter"]["counter1"]["energy_imported_pv"] == 2227.531 + assert result["counter"]["counter1"]["energy_imported_bat"] == 706.684 + assert result["counter"]["counter1"]["energy_imported_cp"] == 180.03 + # counter0 should not have these fields as it's a grid counter + assert "energy_imported_grid" not in result["counter"]["counter0"] + assert "energy_imported_pv" not in result["counter"]["counter0"] + assert "energy_imported_bat" not in result["counter"]["counter0"] + assert "energy_imported_cp" not in result["counter"]["counter0"] + assert message == "" + + +def test_calc_energy_imported_by_source_message_filtering(): + """Test message filtering when component is in fault state and name is missing.""" + # setup + entry = { + "timestamp": 1234567890, + "energy_source": {"grid": 0.6523, "pv": 0.2487, "bat": 0.0789, "cp": 0.0201}, + "cp": { + "cp1": {"energy_imported": 15723.4, "fault_state": 0}, + "cp2": {"energy_imported": 22108.7, "fault_state": 2} # fault state + }, + "counter": { + "counter0": {"grid": True, "energy_imported": 45892.3, "fault_state": 0}, + "counter1": {"grid": False, "energy_imported": 8956.7, "fault_state": 2} # fault state + } + } + + # Names dict is missing keys for cp2 and counter1 + names = { + "cp1": "Ladepunkt 1", + "counter0": "EVU-Zähler" + # cp2 and counter1 intentionally missing + } + + # execution - filter messages only for cp2 + result, message = calc_energy_imported_by_source(entry, names, message_key_filter="cp2") + + # evaluation + # Should only get message for cp2, not counter1 (due to filtering) + expected_message = ("Die Anteile der Energiequellen für Ladepunkt cp2 konnten nicht berechnet werden, da er sich " + "im Fehlerzustand befindet. Die Verbräuche werden mit 0 kWh angesetzt.\n") + assert message == expected_message + + # cp2 should have zero values for all energy sources due to fault state + assert result["cp"]["cp2"]["energy_imported_grid"] == 0 + assert result["cp"]["cp2"]["energy_imported_pv"] == 0 + assert result["cp"]["cp2"]["energy_imported_bat"] == 0 + assert result["cp"]["cp2"]["energy_imported_cp"] == 0 + + # cp1 should have normal calculated values (not in fault state) + assert result["cp"]["cp1"]["energy_imported_grid"] == 10256.374 + + # counter1 should have zero values but no message (filtered out) + assert result["counter"]["counter1"]["energy_imported_grid"] == 0 + assert result["counter"]["counter1"]["energy_imported_pv"] == 0 + assert result["counter"]["counter1"]["energy_imported_bat"] == 0 + assert result["counter"]["counter1"]["energy_imported_cp"] == 0 + + +def test_convert(daily_log_entry_processed, daily_log_sample): + # setup and execution + entry = process_entry(daily_log_sample[0], daily_log_sample[1], CalculationType.ALL) + + # evaluation + assert entry == daily_log_entry_processed + + +def test_pv_export_and_bat_export_and_bat_import(): + entry = { + "timestamp": 1234567890, + "date": "00:00", + "bat": { + "all": { + "energy_imported": 1.0, + "energy_exported": 5.0, + "fault_state": 0, + } + }, + "cp": { + "all": { + "energy_imported": 0.0, + "energy_exported": 0.0, + "fault_state": 0, + } + }, + "pv": { + "all": { + "energy_exported": 10.0, + "fault_state": 0, + } + }, + "counter": { + "counter0": { + "grid": True, + "energy_imported": 2.0, + "energy_exported": 3.0, + "fault_state": 0, + } + } + } + + # Einspeisung - Priorität + # 1 Pv + # 2 Bat + # 3 CP + # -> erst komplette Einspeisung von PV berücksichtigen + # -> wenn dann noch weitere Einspeisung übrig ist -> Bat und dann CP + + # Speicherimport - Priorität + # 1 Pv + # 2 Grid + # 3 CP + + # Pv 10 Exported + # Grid 2 Imported, 3 Exported + # Bat 1 Imported, 5 Exported + + # Realer Verbrauch + # 2 - 3 + 10 + 5 - 1 + 0 = 13 + + # Export aufteilen + # Pv - Export = 10 - 3 = 7 + + # Bat import aufteilen + # Pv - Bat_imported = 7 - 1 = 6 + + # Tatsächlicher Verbrauch: + # Pv = 6 + # Grid = 2 + # Bat = 5 + # --------------------- + # Summe = 13 + + # Anteil der Energiequellen: + # Grid: 2/13 ≈ 0.1538 + # PV = 6/13 ≈ 0.4615 + # Bat = 5/13 ≈ 0.3846 + # CP = 0/13 ≈ 0.0 + result, message = analyse_percentage(entry) + + assert result["energy_source"] == { + "grid": 0.1538, + "pv": 0.4615, + "bat": 0.3846, + "cp": 0.0 + } + assert message == "" + + +@pytest.mark.parametrize( + "name, pv_exported, bat_exported, bat_imported, cp_exported, grid_imported, grid_exported, expected", + [ + ( + "grid import and export", + 10.0, # pv_exported + 0.0, # bat_exported + 0.0, # bat_imported + 0.0, # cp_exported + 2.0, # grid_imported + 3.0, # grid_exported + # 2/9 7/9 + {"grid": 0.2222, "pv": 0.7778, "bat": 0.0, "cp": 0.0} + ), + ( + "grid export proportional pv and bat", + 10.0, # pv_exported + 5.0, # bat_exported + 0.0, # bat_imported + 0.0, # cp_exported + 0.0, # grid_imported + 3.0, # grid_exported + # 0/12 7/12 5/12 + {"grid": 0.0, "pv": 0.5833, "bat": 0.4167, "cp": 0.0} + ), + ( + "grid export and bat import", + 10.0, # pv_exported + 5.0, # bat_exported + 1.0, # bat_imported + 0.0, # cp_exported + 2.0, # grid_imported + 3.0, # grid_exported + # 2/13 6/13 5/13 + {"grid": 0.1538, "pv": 0.4615, "bat": 0.3846, "cp": 0.0} + ), + ( + "grid export proportional pv and cp ", + 6.0, # pv_exported + 0.0, # bat_exported + 0.0, # bat_imported + 3.0, # cp_exported + 0.0, # grid_imported + 3.0, # grid_exported + # 0/6 3/6 0/6 3/6 + {"grid": 0.0, "pv": 0.5, "bat": 0.0, "cp": 0.5} + ), + ( + "bat import proportional pv and grid", + 8.0, # pv_exported + 0.0, # bat_exported + 2.0, # bat_imported + 0.0, # cp_exported + 2.0, # grid_imported + 0.0, # grid_exported + # 2/8 6/8 0/8 8/8 + {"grid": 0.25, "pv": 0.75, "bat": 0.0, "cp": 0.0} + ), + ] +) +def test_analyse_percentage_proportional_distribution(name, + pv_exported, + bat_exported, + bat_imported, + cp_exported, + grid_imported, + grid_exported, expected): + entry = { + "timestamp": 1234567890, + "date": "00:00", + "bat": { + "all": { + "energy_imported": bat_imported, + "energy_exported": bat_exported, + "fault_state": 0, + } + }, + "cp": { + "all": { + "energy_exported": cp_exported, + "fault_state": 0, + } + }, + "pv": { + "all": { + "energy_exported": pv_exported, + "fault_state": 0, + } + }, + "counter": { + "counter0": { + "grid": True, + "energy_imported": grid_imported, + "energy_exported": grid_exported, + "fault_state": 0, + } + } + } + + result, message = analyse_percentage(entry) + + assert result["energy_source"] == expected + assert message == "" + + # Strommix muss zusammen 100% ergeben + assert sum(result["energy_source"].values()) == pytest.approx(1.0, abs=0.0002) diff --git a/packages/helpermodules/measurement_logging/process_log_entry_builder.py b/packages/helpermodules/measurement_logging/process_log_entry_builder.py new file mode 100644 index 0000000000..aeac157977 --- /dev/null +++ b/packages/helpermodules/measurement_logging/process_log_entry_builder.py @@ -0,0 +1,225 @@ + +import logging +from math import isnan +import re + +from paho.mqtt.client import Client as MqttClient, MQTTMessage +from typing import Dict, Optional, Union + +from control import data +from helpermodules.broker import BrokerClient +from helpermodules import timecheck +from helpermodules.utils.topic_parser import decode_payload, get_index +log = logging.getLogger(__name__) + + +class LegacySmartHomeLogData: + def __init__(self) -> None: + self.all_received_topics: Dict = {} + self.sh_dict: Dict = {} + self.sh_names: Dict = {} + try: + BrokerClient("smart-home-logging", self.on_connect, self.on_message).start_finite_loop() + for topic, payload in self.all_received_topics.items(): + if re.search("openWB/LegacySmartHome/config/get/Devices/[1-9]/device_configured", topic) is not None: + if decode_payload(payload) == 1: + index = get_index(topic) + self.sh_dict.update({f"sh{index}": {}}) + for topic, payload in self.all_received_topics.items(): + if f"openWB/LegacySmartHome/Devices/{index}/Wh" == topic: + self.sh_dict[f"sh{index}"].update({"imported": decode_payload(payload), "exported": 0}) + for sensor_id in range(0, 3): + if f"openWB/LegacySmartHome/Devices/{index}/TemperatureSensor{sensor_id}" == topic: + self.sh_dict[f"sh{index}"].update({f"temp{sensor_id}": decode_payload(payload)}) + for topic, payload in self.all_received_topics.items(): + if f"openWB/LegacySmartHome/config/get/Devices/{index}/device_name" == topic: + self.sh_names.update({f"sh{index}": decode_payload(payload)}) + except Exception: + log.exception("Fehler im Werte-Logging-Modul für SmartHome") + + def on_connect(self, client: MqttClient, userdata, flags: dict, rc: int): + client.subscribe("openWB/LegacySmartHome/#", 2) + + def on_message(self, client: MqttClient, userdata, msg: MQTTMessage): + self.all_received_topics.update({msg.topic: msg.payload}) + + +def create_entry(sh_log_data: LegacySmartHomeLogData, previous_entry: Optional[Dict]) -> Dict: + date = timecheck.create_timestamp_HH_MM() + current_timestamp = int(timecheck.create_timestamp()) + + try: + prices = data.data.general_data.data.prices + try: + grid_price = data.data.optional_data.ep_get_current_price() + fault_state = max(data.data.optional_data.data.electricity_pricing.flexible_tariff.get.fault_state, + data.data.optional_data.data.electricity_pricing.grid_fee.get.fault_state) + except Exception: + grid_price = prices.grid + fault_state = 0 + prices_dict = {"grid": grid_price, + "pv": prices.pv, + "bat": prices.bat, + "cp": prices.cp, + "fault_state": fault_state} + except Exception: + log.exception("Fehler im Werte-Logging-Modul für Preise") + prices_dict = {"fault_state": 0} + + try: + cp_dict = {"all": {"imported": data.data.cp_all_data.data.get.imported, + "exported": data.data.cp_all_data.data.get.exported, + "fault_state": data.data.cp_all_data.data.get.fault_state}} + except Exception: + log.exception("Fehler im Werte-Logging-Modul") + cp_dict = {} + for cp in data.data.cp_data: + try: + if "cp" in cp: + cp_dict.update({cp: {"imported": data.data.cp_data[cp].data.get.imported, + "exported": data.data.cp_data[cp].data.get.exported, + "fault_state": data.data.cp_data[cp].data.get.fault_state}}) + except Exception: + log.exception("Fehler im Werte-Logging-Modul für Ladepunkt "+str(cp)) + + ev_dict = {} + for ev in data.data.ev_data: + try: + if "ev" in ev: + ev_dict.update( + {ev: {"soc": data.data.ev_data[ev].data.get.soc, + "fault_state": data.data.ev_data[ev].data.get.fault_state}}) + except Exception: + log.exception("Fehler im Werte-Logging-Modul für EV "+str(ev)) + + counter_dict = {} + counter_all_data = data.data.counter_all_data + # Zählt alle effektiven Hausverbrauchs-Zähler, auch bei Auto-Vererbung über den Parent. + is_home_consumption_by_counter = {} + for current_counter in data.data.counter_data.values(): + try: + is_home_consumption_by_counter[current_counter.num] = counter_all_data.is_home_consumption_counter( + current_counter.num) + except Exception: + log.exception("Fehler beim Ermitteln der Hausverbrauchszähler.") + is_home_consumption_by_counter[current_counter.num] = False + + home_consumption_counter_count = sum(1 for is_hc in is_home_consumption_by_counter.values() if is_hc) + + for counter in data.data.counter_data.values(): + try: + # Der EVU-Zähler muss immer geloggt werden, unabhängig von Hausverbrauchs- oder + # extra_meter-Zuordnung - sonst findet process_log.get_grid_counter() keinen Netzzähler mehr. + is_grid_counter = counter_all_data.get_id_evu_counter() == counter.num + is_home_consumption_counter = is_home_consumption_by_counter.get(counter.num, False) + if not is_grid_counter and is_home_consumption_counter and home_consumption_counter_count == 1: + continue + skip_counter = False + if not is_grid_counter: + for consumer in data.data.consumer_data: + if counter.num == data.data.consumer_data[consumer].data.extra_meter: + skip_counter = True + break + if skip_counter is False: + counter_dict.update( + {f"counter{counter.num}": { + "imported": counter.data.get.imported, + "exported": counter.data.get.exported, + "grid": is_grid_counter, + "fault_state": counter.data.get.fault_state}}) + except Exception: + log.exception("Fehler im Werte-Logging-Modul für Zähler "+str(counter)) + + try: + pv_dict = {"all": {"exported": data.data.pv_all_data.data.get.exported, + "fault_state": data.data.pv_all_data.data.get.fault_state}} + except Exception: + log.exception("Fehler im Werte-Logging-Modul für PV-Daten") + pv_dict = {} + if data.data.pv_all_data.data.config.configured: + for pv in data.data.pv_data: + try: + pv_dict.update( + {pv: {"exported": data.data.pv_data[pv].data.get.exported, + "fault_state": data.data.pv_data[pv].data.get.fault_state}}) + except Exception: + log.exception("Fehler im Werte-Logging-Modul für Wechselrichter "+str(pv)) + + try: + bat_dict = {"all": {"imported": data.data.bat_all_data.data.get.imported, + "exported": data.data.bat_all_data.data.get.exported, + "soc": data.data.bat_all_data.data.get.soc, + "fault_state": data.data.bat_all_data.data.get.fault_state}} + except Exception: + log.exception("Fehler im Werte-Logging-Modul für Batteriespeicher-Daten") + bat_dict = {} + if data.data.bat_all_data.data.config.configured: + for bat in data.data.bat_data: + try: + bat_dict.update({bat: {"imported": data.data.bat_data[bat].data.get.imported, + "exported": data.data.bat_data[bat].data.get.exported, + "soc": data.data.bat_data[bat].data.get.soc, + "fault_state": data.data.bat_data[bat].data.get.fault_state}}) + except Exception: + log.exception("Fehler im Werte-Logging-Modul für Speicher "+str(bat)) + + try: + consumer_dict: Dict[str, Dict[str, Union[float, int]]] = {"all": { + "imported": data.data.consumer_all_data.data.get.imported, + "exported": data.data.consumer_all_data.data.get.exported, + "fault_state": data.data.consumer_all_data.data.get.fault_state}} + except Exception: + log.exception("Fehler im Werte-Logging-Modul für Verbraucher-Daten") + consumer_dict = {} + for consumer in data.data.consumer_data: + try: + consumer_dict.update({consumer: {"imported": data.data.consumer_data[consumer].data.get.imported, + "exported": data.data.consumer_data[consumer].data.get.exported, + "fault_state": data.data.consumer_data[consumer].data.get.fault_state}}) + except Exception: + log.exception("Fehler im Werte-Logging-Modul für Verbraucher "+str(consumer)) + + try: + hc_dict = {"all": { + "imported": data.data.counter_all_data.data.set.imported_home_consumption, + "fault_state": 2 if data.data.counter_all_data.data.set.invalid_home_consumption >= 3 else 0}} + except Exception: + log.exception("Fehler im Werte-Logging-Modul für Hausverbrauch") + hc_dict = {} + new_entry = { + "timestamp": current_timestamp, + "date": date, + "prices": prices_dict, + "cp": cp_dict, + "ev": ev_dict, + "counter": counter_dict, + "pv": pv_dict, + "bat": bat_dict, + "consumer": consumer_dict, + "sh": sh_log_data.sh_dict, + "hc": hc_dict + } + + return fix_values(new_entry, previous_entry) + + +def fix_values(new_entry: Dict, previous_entry: Optional[Dict]) -> Dict: + def find_and_fix_value(value_name): + if value.get(value_name) is not None: + if value[value_name] == 0: + try: + if (previous_entry[group][component][value_name] is not None and + isnan(previous_entry[group][component][value_name]) is False): + value[value_name] = previous_entry[group][component][value_name] + except KeyError: + log.exception("Es konnte kein vorheriger Wert gefunden werden.") + if previous_entry is not None: + for group, value in new_entry.items(): + if group not in ("bat", "counter", "cp", "pv", "hc"): + continue + for component, value in value.items(): + find_and_fix_value("exported") + find_and_fix_value("imported") + else: + log.warning("Keine vorherigen Werte vorhanden, um aktuelle Werte auf Plausibilität zu prüfen.") + return new_entry diff --git a/packages/helpermodules/measurement_logging/process_log_unit_test.py b/packages/helpermodules/measurement_logging/process_log_unit_test.py index b4da9869f1..159a29829b 100644 --- a/packages/helpermodules/measurement_logging/process_log_unit_test.py +++ b/packages/helpermodules/measurement_logging/process_log_unit_test.py @@ -5,15 +5,15 @@ from unittest.mock import Mock, mock_open import pytest + from helpermodules.measurement_logging.process_log import ( analyse_percentage, - _calculate_average_power, - process_entry, get_totals, _collect_daily_log_data, - calc_energy_imported_by_source, analyse_percentage_totals, - CalculationType) + _get_last_entry_for_period, + _apply_source_totals, +) def test_get_totals(daily_log_sample, daily_log_totals): @@ -153,61 +153,6 @@ def test_analyse_percentage_edge_cases(test_case, entry_data, expected_energy_so assert message == "" -def test_calculate_average_power(): - # setup and execution - power = _calculate_average_power(100, 250, 300) - - # evaluation - assert power == 1800 - - -def test_calc_energy_imported_by_source(): - # setup - entry = { - "timestamp": 1234567890, - "energy_source": {"grid": 0.6523, "pv": 0.2487, "bat": 0.0789, "cp": 0.0201}, - "hc": {"all": {"energy_imported": 2345.6, "fault_state": 0}}, - "cp": { - "cp1": {"energy_imported": 15723.4, "fault_state": 0}, - "cp2": {"energy_imported": 22108.7, "fault_state": 0} - }, - "counter": { - "counter0": {"grid": True, "energy_imported": 45892.3, "fault_state": 0}, - "counter1": {"grid": False, "energy_imported": 8956.7, "fault_state": 0} - } - } - - # execution - result, message = calc_energy_imported_by_source(entry, {}) - - # evaluation - realistic Wh values with decimal precision - assert result["hc"]["all"]["energy_imported_grid"] == 1530.035 - assert result["hc"]["all"]["energy_imported_pv"] == 583.351 - assert result["hc"]["all"]["energy_imported_bat"] == 185.068 - assert result["hc"]["all"]["energy_imported_cp"] == 47.147 - - assert result["cp"]["cp1"]["energy_imported_grid"] == 10256.374 - assert result["cp"]["cp1"]["energy_imported_pv"] == 3910.41 - assert result["cp"]["cp1"]["energy_imported_bat"] == 1240.576 - assert result["cp"]["cp1"]["energy_imported_cp"] == 316.04 - - assert result["cp"]["cp2"]["energy_imported_grid"] == 14421.505 - assert result["cp"]["cp2"]["energy_imported_pv"] == 5498.434 - assert result["cp"]["cp2"]["energy_imported_bat"] == 1744.376 - assert result["cp"]["cp2"]["energy_imported_cp"] == 444.385 - - assert result["counter"]["counter1"]["energy_imported_grid"] == 5842.455 - assert result["counter"]["counter1"]["energy_imported_pv"] == 2227.531 - assert result["counter"]["counter1"]["energy_imported_bat"] == 706.684 - assert result["counter"]["counter1"]["energy_imported_cp"] == 180.03 - # counter0 should not have these fields as it's a grid counter - assert "energy_imported_grid" not in result["counter"]["counter0"] - assert "energy_imported_pv" not in result["counter"]["counter0"] - assert "energy_imported_bat" not in result["counter"]["counter0"] - assert "energy_imported_cp" not in result["counter"]["counter0"] - assert message == "" - - def test_analyse_percentage_totals(): # setup current_dir = os.path.dirname(os.path.abspath(__file__)) @@ -334,62 +279,6 @@ def test_analyse_percentage_totals_entry_missing_grid_counter(): assert result["counter"]["counter0"]["energy_imported_cp"] == 10 -def test_calc_energy_imported_by_source_message_filtering(): - """Test message filtering when component is in fault state and name is missing.""" - # setup - entry = { - "timestamp": 1234567890, - "energy_source": {"grid": 0.6523, "pv": 0.2487, "bat": 0.0789, "cp": 0.0201}, - "cp": { - "cp1": {"energy_imported": 15723.4, "fault_state": 0}, - "cp2": {"energy_imported": 22108.7, "fault_state": 2} # fault state - }, - "counter": { - "counter0": {"grid": True, "energy_imported": 45892.3, "fault_state": 0}, - "counter1": {"grid": False, "energy_imported": 8956.7, "fault_state": 2} # fault state - } - } - - # Names dict is missing keys for cp2 and counter1 - names = { - "cp1": "Ladepunkt 1", - "counter0": "EVU-Zähler" - # cp2 and counter1 intentionally missing - } - - # execution - filter messages only for cp2 - result, message = calc_energy_imported_by_source(entry, names, message_key_filter="cp2") - - # evaluation - # Should only get message for cp2, not counter1 (due to filtering) - expected_message = ("Die Anteile der Energiequellen für Ladepunkt cp2 konnten nicht berechnet werden, da er sich " - "im Fehlerzustand befindet. Die Verbräuche werden mit 0 kWh angesetzt.\n") - assert message == expected_message - - # cp2 should have zero values for all energy sources due to fault state - assert result["cp"]["cp2"]["energy_imported_grid"] == 0 - assert result["cp"]["cp2"]["energy_imported_pv"] == 0 - assert result["cp"]["cp2"]["energy_imported_bat"] == 0 - assert result["cp"]["cp2"]["energy_imported_cp"] == 0 - - # cp1 should have normal calculated values (not in fault state) - assert result["cp"]["cp1"]["energy_imported_grid"] == 10256.374 - - # counter1 should have zero values but no message (filtered out) - assert result["counter"]["counter1"]["energy_imported_grid"] == 0 - assert result["counter"]["counter1"]["energy_imported_pv"] == 0 - assert result["counter"]["counter1"]["energy_imported_bat"] == 0 - assert result["counter"]["counter1"]["energy_imported_cp"] == 0 - - -def test_convert(daily_log_entry_processed, daily_log_sample): - # setup and execution - entry = process_entry(daily_log_sample[0], daily_log_sample[1], CalculationType.ALL) - - # evaluation - assert entry == daily_log_entry_processed - - def test_collect_daily_log_data_current_day(monkeypatch): # setup test_date = "20240422" @@ -506,191 +395,82 @@ def test_collect_daily_log_data_json_decode_error(monkeypatch): assert result == expected_result -def test_pv_export_and_bat_export_and_bat_import(): +def test_apply_source_totals_updates_existing_and_creates_missing_sections(): + # setup entry = { - "timestamp": 1234567890, - "date": "00:00", - "bat": { - "all": { - "energy_imported": 1.0, - "energy_exported": 5.0, - "fault_state": 0, - } + # required by analyse_percentage(), which _apply_source_totals() calls internally + "date": "22.04.2024", + "timestamp": 1713772800, + "counter": { + "counter0": {"grid": True, "energy_imported": 0, "energy_exported": 0} }, "cp": { - "all": { - "energy_imported": 0.0, - "energy_exported": 0.0, - "fault_state": 0, - } + "all": {"energy_imported": 5, "keep": "x"}, + "cp9": {"keep_cp": True} }, - "pv": { - "all": { - "energy_exported": 10.0, - "fault_state": 0, - } + "meta": {"unchanged": True} + } + daily_totals = { + "cp": { + "all": {"energy_imported": 12, "energy_exported": 3}, + "cp1": {"energy_imported": 7}, + "cp_invalid": 99 }, - "counter": { - "counter0": { - "grid": True, - "energy_imported": 2.0, - "energy_exported": 3.0, - "fault_state": 0, - } - } + "hc": { + "all": {"energy_imported": 2} + }, + "invalid_section": "ignore_me" } - # Einspeisung - Priorität - # 1 Pv - # 2 Bat - # 3 CP - # -> erst komplette Einspeisung von PV berücksichtigen - # -> wenn dann noch weitere Einspeisung übrig ist -> Bat und dann CP - - # Speicherimport - Priorität - # 1 Pv - # 2 Grid - # 3 CP - - # Pv 10 Exported - # Grid 2 Imported, 3 Exported - # Bat 1 Imported, 5 Exported - - # Realer Verbrauch - # 2 - 3 + 10 + 5 - 1 + 0 = 13 - - # Export aufteilen - # Pv - Export = 10 - 3 = 7 - - # Bat import aufteilen - # Pv - Bat_imported = 7 - 1 = 6 - - # Tatsächlicher Verbrauch: - # Pv = 6 - # Grid = 2 - # Bat = 5 - # --------------------- - # Summe = 13 - - # Anteil der Energiequellen: - # Grid: 2/13 ≈ 0.1538 - # PV = 6/13 ≈ 0.4615 - # Bat = 5/13 ≈ 0.3846 - # CP = 0/13 ≈ 0.0 - result, message = analyse_percentage(entry) + # execution + result = _apply_source_totals(entry, daily_totals) - assert result["energy_source"] == { - "grid": 0.1538, - "pv": 0.4615, - "bat": 0.3846, - "cp": 0.0 - } - assert message == "" + # evaluation + # in-place behavior + assert result is entry + + # existing module gets overwritten/extended, unrelated fields stay + assert result["cp"]["all"]["energy_imported"] == 12 + assert result["cp"]["all"]["energy_exported"] == 3 + assert result["cp"]["all"]["keep"] == "x" + + # missing module and section are created + assert result["cp"]["cp1"]["energy_imported"] == 7 + assert result["hc"]["all"]["energy_imported"] == 2 + + # invalid totals are ignored + assert "cp_invalid" not in result["cp"] + assert "invalid_section" not in result + + # unrelated data stays unchanged + assert result["cp"]["cp9"]["keep_cp"] is True + assert result["meta"]["unchanged"] is True @pytest.mark.parametrize( - "name, pv_exported, bat_exported, bat_imported, cp_exported, grid_imported, grid_exported, expected", + "entries, period, period_format, expected", [ ( - "grid import and export", - 10.0, # pv_exported - 0.0, # bat_exported - 0.0, # bat_imported - 0.0, # cp_exported - 2.0, # grid_imported - 3.0, # grid_exported - # 2/9 7/9 - {"grid": 0.2222, "pv": 0.7778, "bat": 0.0, "cp": 0.0} - ), - ( - "grid export proportional pv and bat", - 10.0, # pv_exported - 5.0, # bat_exported - 0.0, # bat_imported - 0.0, # cp_exported - 0.0, # grid_imported - 3.0, # grid_exported - # 0/12 7/12 5/12 - {"grid": 0.0, "pv": 0.5833, "bat": 0.4167, "cp": 0.0} + [ + {"timestamp": 1711929600, "value": "april_1"}, + {"timestamp": 1712016000, "value": "april_2"}, + {"timestamp": 1714608000, "value": "may_2"}, + ], + "202404", + "%Y%m", + {"timestamp": 1712016000, "value": "april_2"}, ), ( - "grid export and bat import", - 10.0, # pv_exported - 5.0, # bat_exported - 1.0, # bat_imported - 0.0, # cp_exported - 2.0, # grid_imported - 3.0, # grid_exported - # 2/13 6/13 5/13 - {"grid": 0.1538, "pv": 0.4615, "bat": 0.3846, "cp": 0.0} + [], + "202404", + "%Y%m", + None, ), - ( - "grid export proportional pv and cp ", - 6.0, # pv_exported - 0.0, # bat_exported - 0.0, # bat_imported - 3.0, # cp_exported - 0.0, # grid_imported - 3.0, # grid_exported - # 0/6 3/6 0/6 3/6 - {"grid": 0.0, "pv": 0.5, "bat": 0.0, "cp": 0.5} - ), - ( - "bat import proportional pv and grid", - 8.0, # pv_exported - 0.0, # bat_exported - 2.0, # bat_imported - 0.0, # cp_exported - 2.0, # grid_imported - 0.0, # grid_exported - # 2/8 6/8 0/8 8/8 - {"grid": 0.25, "pv": 0.75, "bat": 0.0, "cp": 0.0} - ), - ] + ], ) -def test_analyse_percentage_proportional_distribution(name, - pv_exported, - bat_exported, - bat_imported, - cp_exported, - grid_imported, - grid_exported, expected): - entry = { - "timestamp": 1234567890, - "date": "00:00", - "bat": { - "all": { - "energy_imported": bat_imported, - "energy_exported": bat_exported, - "fault_state": 0, - } - }, - "cp": { - "all": { - "energy_exported": cp_exported, - "fault_state": 0, - } - }, - "pv": { - "all": { - "energy_exported": pv_exported, - "fault_state": 0, - } - }, - "counter": { - "counter0": { - "grid": True, - "energy_imported": grid_imported, - "energy_exported": grid_exported, - "fault_state": 0, - } - } - } - - result, message = analyse_percentage(entry) - - assert result["energy_source"] == expected - assert message == "" +def test_get_last_entry_for_period(entries, period, period_format, expected): + # execution + result = _get_last_entry_for_period(entries, period, period_format) - # Strommix muss zusammen 100% ergeben - assert sum(result["energy_source"].values()) == pytest.approx(1.0, abs=0.0002) + # evaluation + assert result == expected diff --git a/packages/helpermodules/measurement_logging/update_yields.py b/packages/helpermodules/measurement_logging/update_yields.py index 9f8f3a3139..87e845703d 100644 --- a/packages/helpermodules/measurement_logging/update_yields.py +++ b/packages/helpermodules/measurement_logging/update_yields.py @@ -1,11 +1,14 @@ -import json import logging from pathlib import Path from typing import Dict from control import data from helpermodules import timecheck -from helpermodules.measurement_logging.process_log import get_totals +from helpermodules.measurement_logging.process_log import (get_totals, + load_daily_source_totals_content, + load_monthly_source_totals_content, + save_daily_source_totals, + get_monthly_log) log = logging.getLogger(__name__) @@ -17,6 +20,7 @@ def update_daily_yields(entries): totals = get_totals(entries) [update_module_yields(type, totals) for type in ("bat", "consumer", "counter", "cp", "pv")] data.data.counter_all_data.data.set.daily_yield_home_consumption = totals["hc"]["all"]["energy_imported"] + return totals except Exception: log.exception("Fehler beim Veröffentlichen der Tageserträge.") @@ -40,72 +44,115 @@ def update_module_yields(module: str, totals: Dict) -> None: log.exception(f"Fehler beim Aktualisieren der Tageserträge für Modul {m} vom Typ {module}.") -def update_pv_monthly_yearly_yields(): - """ veröffentlicht die monatlichen und jährlichen Erträge für PV +def update_pv_monthly_yearly_yields(daily_totals: Dict) -> None: """ - _update_pv_monthly_yields() - _update_pv_yearly_yields() + veröffentlicht die monatlichen und jährlichen Erträge für PV + """ + # pv_data und pv_all_data werden nur aktualisiert, wenn das Update der Logfiles abgeschlossen ist. + if not data.data.system_data["system"].data.get("log_data_ready", False): + log.debug("Update der Logfiles läuft noch. Monatliche und jährliche PV-Erträge werden nicht aktualisiert.") + return + + monthly_totals = _get_pv_monthly_yields(daily_totals) + yearly_totals = _get_pv_yearly_yields(monthly_totals) + + pv_all_monthly_yield = 0 + pv_all_yearly_yield = 0 + + for pv_key, pv_module in data.data.pv_data.items(): + if pv_key == "all" or not hasattr(pv_module, "num"): + continue + + # Was wurde im Monat/Jahr exportiert + monthly_yield = monthly_totals.get(f"pv{pv_module.num}", {}).get("energy_exported", 0) + yearly_yield = yearly_totals.get(f"pv{pv_module.num}", {}).get("energy_exported", 0) + + data.data.pv_data[f"pv{pv_module.num}"].data.get.monthly_exported = monthly_yield + data.data.pv_data[f"pv{pv_module.num}"].data.get.yearly_exported = yearly_yield + + # Summe über alle Module für pv_all + pv_all_monthly_yield += monthly_yield + pv_all_yearly_yield += yearly_yield + + data.data.pv_all_data.data.get.monthly_exported = pv_all_monthly_yield + data.data.pv_all_data.data.get.yearly_exported = pv_all_yearly_yield -def _update_pv_monthly_yields(): - """ veröffentlicht die monatlichen Erträge für PV - für pv_all nicht die Differenz aus den Logs nehmen, sondern die Summe der Module. Wenn im laufenden Monat ein Modul - gelöscht wurde und keins oder eines mit niedrigerem Zählerstand hinzugefügt wird, wird sonst ein negativer Wert - ermittelt. +def _get_pv_monthly_yields(daily_totals: Dict) -> Dict: """ - try: - pv_all_monthly_yield = 0 - with open(f"data/monthly_log/{timecheck.create_timestamp_YYYYMM()}.json", "r") as f: - monthly_log = json.load(f) - for pv_module in data.data.pv_data.values(): - for entry in monthly_log["entries"]: - if entry["pv"].get(f"pv{pv_module.num}"): - monthly_yield = data.data.pv_data[f"pv{pv_module.num}"].data.get.exported - \ - entry["pv"][f"pv{pv_module.num}"]["exported"] - pv_all_monthly_yield += monthly_yield - data.data.pv_data[f"pv{pv_module.num}"].data.get.monthly_exported = monthly_yield - break - data.data.pv_all_data.data.get.monthly_exported = pv_all_monthly_yield - except FileNotFoundError: - # am Tag der Ersteinrichtung gibt es noch kein Monatslog-File, das wird erst um Mitternacht erstellt. - log.debug("No monthly logfile found for calculation of monthly yield") - except Exception: - log.exception("Fehler beim Veröffentlichen der monatlichen Erträge für PV") + Berechnet den Unterschied zwischen dem Zählerstand vom ersten Tag des aktuellen Monats bis zum aktuellen Tag. + """ + + this_month = timecheck.create_timestamp_YYYYMM() + today = timecheck.create_timestamp_YYYYMMDD() + + pv_totals = {} + + daily_log_path = _get_parent_path()/"data"/"daily_log" + + for logfile in sorted(daily_log_path.glob(f"{this_month}*.json")): + day = logfile.stem + totals = {} + if day == today: + continue # Der aktuelle Tag wird später behandelt + else: + # Lade alle vergangenen Tage des Monats aus dem daily_totals-Logfile, um die Tageserträge zu ermitteln + content = load_daily_source_totals_content(day) + if content is None: + # Fallback/Migration: fehlende Tages-Totals aus den Tageslogs berechnen und speichern. + content = save_daily_source_totals(day, saving=True) + if isinstance(content, dict): + totals = content.get("totals", {}) + + # Totals aufsummieren + _add_pv_totals(pv_totals, totals.get("pv", {})) + # aktueller Tag ergänzen + _add_pv_totals(pv_totals, daily_totals.get("pv", {})) -def _update_pv_yearly_yields(): - """ veröffentlicht die jährlichen Erträge für PV - für pv_all nicht die Differenz aus den Logs nehmen, sondern die Summe der Module. Wenn unterjährig ein Modul - gelöscht wurde und keins oder eines mit niedrigerem Zählerstand hinzugefügt wird, wird sonst ein negativer Wert - ermittelt. + return pv_totals + + +def _get_pv_yearly_yields(current_monthly_totals: Dict) -> Dict: """ - try: - pv_all_yearly_yield = 0 - path_list = list(Path(_get_parent_path()/"data"/"monthly_log").glob(f"{timecheck.create_timestamp_YYYY()}*")) - sorted_path_list = sorted([str(p) for p in path_list]) - for pv_module in data.data.pv_data.values(): - found_pv = False - for path in sorted_path_list: - with open(path, "r") as f: - monthly_log = json.load(f) - for entry in monthly_log["entries"]: - # erster Eintrag mit PV im Jahr, falls WR erst im laufenden Jahr hinzugefügt wurden - if entry["pv"].get(f"pv{pv_module.num}"): - yearly_yield = data.data.pv_data[f"pv{pv_module.num}"].data.get.exported - \ - entry["pv"][f"pv{pv_module.num}"]["exported"] - pv_all_yearly_yield += yearly_yield - data.data.pv_data[f"pv{pv_module.num}"].data.get.yearly_exported = yearly_yield - found_pv = True - break - if found_pv: - break - else: - # am Tag der Ersteinrichtung gibt es noch kein Monatslog-File, das wird erst um Mitternacht erstellt. - log.debug("No monthly logfile found for calculation of yearly yield") - data.data.pv_all_data.data.get.yearly_exported = pv_all_yearly_yield - except Exception: - log.exception("Fehler beim Veröffentlichen der jährlichen Erträge für PV") + Berechnet den Unterschied zwischen dem Zählerstand vom ersten Monat des aktuellen Jahres bis zum aktuellen Monat. + """ + this_year = timecheck.create_timestamp_YYYY() + this_month = timecheck.create_timestamp_YYYYMM() + + pv_totals = {} + + month = f"{this_year}01" + while month < this_month: + totals = {} + content = load_monthly_source_totals_content(month) + if content is None: + # Fallback/Migration: fehlende Monats-Totals berechnen und speichern. + content = get_monthly_log(month) + if isinstance(content, dict): + totals = content.get("totals", {}) + + # Totals aufsummieren + _add_pv_totals(pv_totals, totals.get("pv", {})) + month = timecheck.get_relative_date_string(month, month_offset=1) + + # aktueller Monat ergänzen + _add_pv_totals(pv_totals, current_monthly_totals) + + return pv_totals def _get_parent_path() -> Path: return Path(__file__).resolve().parents[3] + + +def _add_pv_totals(target: Dict, source: Dict) -> None: + for pv_key, values in source.items(): + energy_exported = values.get("energy_exported", 0) + + # Wenn das PV-Modul noch nicht im target ist, initialisiere es mit 0 + if pv_key not in target: + target[pv_key] = {"energy_exported": 0} + + # Addiere die energy_exported Werte für das PV-Modul + target[pv_key]["energy_exported"] += energy_exported diff --git a/packages/helpermodules/measurement_logging/update_yields_test.py b/packages/helpermodules/measurement_logging/update_yields_test.py index 35a3fd7b81..55bbff1a1f 100644 --- a/packages/helpermodules/measurement_logging/update_yields_test.py +++ b/packages/helpermodules/measurement_logging/update_yields_test.py @@ -1,5 +1,11 @@ +from unittest.mock import Mock + from control import data -from helpermodules.measurement_logging.update_yields import update_module_yields +from helpermodules.measurement_logging import update_yields +from helpermodules.measurement_logging.update_yields import ( + update_module_yields, + update_pv_monthly_yearly_yields, +) def test_update_module_yields(daily_log_totals, mock_pub): @@ -21,3 +27,67 @@ def test_update_module_yields(daily_log_totals, mock_pub): data.data.cp_data["cp6"].data.get.daily_exported = 0.0 data.data.pv_all_data.data.get.daily_exported = 251.0 data.data.pv_data["pv1"].data.get.daily_exported = 251.0 + + +def test_update_pv_monthly_yearly_yields_skips_when_generation_not_finished(monkeypatch): + # setup + data.data.system_data = {"system": Mock(data={"log_data_ready": False})} + get_monthly_mock = Mock() + get_yearly_mock = Mock() + + monkeypatch.setattr(update_yields, "_get_pv_monthly_yields", get_monthly_mock) + monkeypatch.setattr(update_yields, "_get_pv_yearly_yields", get_yearly_mock) + + # execution + update_pv_monthly_yearly_yields({"pv": {"pv1": {"energy_exported": 12}}}) + + # evaluation + get_monthly_mock.assert_not_called() + get_yearly_mock.assert_not_called() + + +def test_update_pv_monthly_yearly_yields_with_daily_and_monthly_fallback(monkeypatch, tmp_path): + # setup + data.data.system_data = {"system": Mock(data={"log_data_ready": True})} + + parent_path = tmp_path + daily_log_path = parent_path / "data" / "daily_log" + daily_log_path.mkdir(parents=True) + (daily_log_path / "20260201.json").write_text("{}") + (daily_log_path / "20260214.json").write_text("{}") + (daily_log_path / "20260215.json").write_text("{}") + + monkeypatch.setattr(update_yields, "_get_parent_path", lambda: parent_path) + monkeypatch.setattr(update_yields.timecheck, "create_timestamp_YYYY", lambda: "2026") + monkeypatch.setattr(update_yields.timecheck, "create_timestamp_YYYYMM", lambda: "202602") + monkeypatch.setattr(update_yields.timecheck, "create_timestamp_YYYYMMDD", lambda: "20260215") + monkeypatch.setattr(update_yields.timecheck, "get_relative_date_string", lambda _m, month_offset=1: "202602") + + load_daily_mock = Mock(side_effect=[ + {"totals": {"pv": {"pv1": {"energy_exported": 10}}}}, + None, + ]) + save_daily_mock = Mock(return_value={"totals": {"pv": {"pv1": {"energy_exported": 20}}}}) + load_monthly_mock = Mock(return_value=None) + get_monthly_mock = Mock(return_value={"totals": {"pv": {"pv1": {"energy_exported": 100}}}}) + + monkeypatch.setattr(update_yields, "load_daily_source_totals_content", load_daily_mock) + monkeypatch.setattr(update_yields, "save_daily_source_totals", save_daily_mock) + monkeypatch.setattr(update_yields, "load_monthly_source_totals_content", load_monthly_mock) + monkeypatch.setattr(update_yields, "get_monthly_log", get_monthly_mock) + + daily_totals = {"pv": {"pv1": {"energy_exported": 5}}} + + # execution + update_pv_monthly_yearly_yields(daily_totals) + + # evaluation + assert data.data.pv_data["pv1"].data.get.monthly_exported == 35 + assert data.data.pv_data["pv1"].data.get.yearly_exported == 135 + assert data.data.pv_all_data.data.get.monthly_exported == 35 + assert data.data.pv_all_data.data.get.yearly_exported == 135 + + assert load_daily_mock.call_count == 2 + save_daily_mock.assert_called_once_with("20260214", saving=True) + load_monthly_mock.assert_called_once_with("202601") + get_monthly_mock.assert_called_once_with("202601") diff --git a/packages/helpermodules/measurement_logging/write_log.py b/packages/helpermodules/measurement_logging/write_log.py index d0b83c7b2b..d36623d670 100644 --- a/packages/helpermodules/measurement_logging/write_log.py +++ b/packages/helpermodules/measurement_logging/write_log.py @@ -1,22 +1,18 @@ -from enum import Enum import os import json import logging -from math import isnan from pathlib import Path -import re import string -from paho.mqtt.client import Client as MqttClient, MQTTMessage -from typing import Dict, Optional, Union +from typing import Dict, Optional from control import data -from helpermodules.broker import BrokerClient from helpermodules import timecheck from helpermodules.constants import DEFAULT_COLORS from helpermodules.utils.json_file_handler import write_and_check -from helpermodules.utils.topic_parser import decode_payload, get_index from modules.common.utils.component_parser import get_component_name_by_id, get_component_color_by_id +from helpermodules.measurement_logging.process_log_entry_builder import LegacySmartHomeLogData, create_entry + log = logging.getLogger(__name__) # erstellt für jeden Tag eine Datei, die die Daten für den Langzeitgraph enthält. @@ -108,56 +104,11 @@ # } -class LogType(Enum): - DAILY = "daily" - MONTHLY = "monthly" - - -class LegacySmartHomeLogData: - def __init__(self) -> None: - self.all_received_topics: Dict = {} - self.sh_dict: Dict = {} - self.sh_names: Dict = {} - try: - BrokerClient("smart-home-logging", self.on_connect, self.on_message).start_finite_loop() - for topic, payload in self.all_received_topics.items(): - if re.search("openWB/LegacySmartHome/config/get/Devices/[1-9]/device_configured", topic) is not None: - if decode_payload(payload) == 1: - index = get_index(topic) - self.sh_dict.update({f"sh{index}": {}}) - for topic, payload in self.all_received_topics.items(): - if f"openWB/LegacySmartHome/Devices/{index}/Wh" == topic: - self.sh_dict[f"sh{index}"].update({"imported": decode_payload(payload), "exported": 0}) - for sensor_id in range(0, 3): - if f"openWB/LegacySmartHome/Devices/{index}/TemperatureSensor{sensor_id}" == topic: - self.sh_dict[f"sh{index}"].update({f"temp{sensor_id}": decode_payload(payload)}) - for topic, payload in self.all_received_topics.items(): - if f"openWB/LegacySmartHome/config/get/Devices/{index}/device_name" == topic: - self.sh_names.update({f"sh{index}": decode_payload(payload)}) - except Exception: - log.exception("Fehler im Werte-Logging-Modul für SmartHome") - - def on_connect(self, client: MqttClient, userdata, flags: dict, rc: int): - client.subscribe("openWB/LegacySmartHome/#", 2) - - def on_message(self, client: MqttClient, userdata, msg: MQTTMessage): - self.all_received_topics.update({msg.topic: msg.payload}) - - -def save_log(log_type: LogType): - """ Parameter - --------- - folder: str - gibt an, ob ein Tages-oder Monats-Log-Eintrag erstellt werden soll. - """ +def save_log(): try: - parent_file = Path(__file__).resolve().parents[3] / "data" / \ - ("daily_log" if log_type == LogType.DAILY else "monthly_log") + parent_file = Path(__file__).resolve().parents[3] / "data" / "daily_log" parent_file.mkdir(mode=0o755, parents=True, exist_ok=True) - if log_type == LogType.DAILY: - file_name = timecheck.create_timestamp_YYYYMMDD() - else: - file_name = timecheck.create_timestamp_YYYYMM() + file_name = timecheck.create_timestamp_YYYYMMDD() filepath = str(parent_file / f"{file_name}.json") try: @@ -173,7 +124,7 @@ def save_log(log_type: LogType): previous_entry = get_previous_entry(parent_file, content) sh_log_data = LegacySmartHomeLogData() - new_entry = create_entry(log_type, sh_log_data, previous_entry) + new_entry = create_entry(sh_log_data, previous_entry) # json-Objekt in Datei einfügen @@ -205,190 +156,6 @@ def get_previous_entry(parent_file: Path, content: Dict) -> Optional[Dict]: return previous_entry -def create_entry(log_type: LogType, sh_log_data: LegacySmartHomeLogData, previous_entry: Optional[Dict]) -> Dict: - if log_type == LogType.DAILY: - date = timecheck.create_timestamp_HH_MM() - else: - date = timecheck.create_timestamp_YYYYMMDD() - current_timestamp = int(timecheck.create_timestamp()) - - try: - prices = data.data.general_data.data.prices - try: - grid_price = data.data.optional_data.ep_get_current_price() - fault_state = max(data.data.optional_data.data.electricity_pricing.flexible_tariff.get.fault_state, - data.data.optional_data.data.electricity_pricing.grid_fee.get.fault_state) - except Exception: - grid_price = prices.grid - fault_state = 0 - prices_dict = {"grid": grid_price, - "pv": prices.pv, - "bat": prices.bat, - "cp": prices.cp, - "fault_state": fault_state} - except Exception: - log.exception("Fehler im Werte-Logging-Modul für Preise") - prices_dict = {"fault_state": 0} - - try: - cp_dict = {"all": {"imported": data.data.cp_all_data.data.get.imported, - "exported": data.data.cp_all_data.data.get.exported, - "fault_state": data.data.cp_all_data.data.get.fault_state}} - except Exception: - log.exception("Fehler im Werte-Logging-Modul") - cp_dict = {} - for cp in data.data.cp_data: - try: - if "cp" in cp: - cp_dict.update({cp: {"imported": data.data.cp_data[cp].data.get.imported, - "exported": data.data.cp_data[cp].data.get.exported, - "fault_state": data.data.cp_data[cp].data.get.fault_state}}) - except Exception: - log.exception("Fehler im Werte-Logging-Modul für Ladepunkt "+str(cp)) - - ev_dict = {} - for ev in data.data.ev_data: - try: - if "ev" in ev: - ev_dict.update( - {ev: {"soc": data.data.ev_data[ev].data.get.soc, - "fault_state": data.data.ev_data[ev].data.get.fault_state}}) - except Exception: - log.exception("Fehler im Werte-Logging-Modul für EV "+str(ev)) - - counter_dict = {} - counter_all_data = data.data.counter_all_data - # Zählt alle effektiven Hausverbrauchs-Zähler, auch bei Auto-Vererbung über den Parent. - is_home_consumption_by_counter = {} - for current_counter in data.data.counter_data.values(): - try: - is_home_consumption_by_counter[current_counter.num] = counter_all_data.is_home_consumption_counter( - current_counter.num) - except Exception: - log.exception("Fehler beim Ermitteln der Hausverbrauchszähler.") - is_home_consumption_by_counter[current_counter.num] = False - - home_consumption_counter_count = sum(1 for is_hc in is_home_consumption_by_counter.values() if is_hc) - - for counter in data.data.counter_data.values(): - try: - # Der EVU-Zähler muss immer geloggt werden, unabhängig von Hausverbrauchs- oder - # extra_meter-Zuordnung - sonst findet process_log.get_grid_counter() keinen Netzzähler mehr. - is_grid_counter = counter_all_data.get_id_evu_counter() == counter.num - is_home_consumption_counter = is_home_consumption_by_counter.get(counter.num, False) - if not is_grid_counter and is_home_consumption_counter and home_consumption_counter_count == 1: - continue - skip_counter = False - if not is_grid_counter: - for consumer in data.data.consumer_data: - if counter.num == data.data.consumer_data[consumer].data.extra_meter: - skip_counter = True - break - if skip_counter is False: - counter_dict.update( - {f"counter{counter.num}": { - "imported": counter.data.get.imported, - "exported": counter.data.get.exported, - "grid": is_grid_counter, - "fault_state": counter.data.get.fault_state}}) - except Exception: - log.exception("Fehler im Werte-Logging-Modul für Zähler "+str(counter)) - - try: - pv_dict = {"all": {"exported": data.data.pv_all_data.data.get.exported, - "fault_state": data.data.pv_all_data.data.get.fault_state}} - except Exception: - log.exception("Fehler im Werte-Logging-Modul für PV-Daten") - pv_dict = {} - if data.data.pv_all_data.data.config.configured: - for pv in data.data.pv_data: - try: - pv_dict.update( - {pv: {"exported": data.data.pv_data[pv].data.get.exported, - "fault_state": data.data.pv_data[pv].data.get.fault_state}}) - except Exception: - log.exception("Fehler im Werte-Logging-Modul für Wechselrichter "+str(pv)) - - try: - bat_dict = {"all": {"imported": data.data.bat_all_data.data.get.imported, - "exported": data.data.bat_all_data.data.get.exported, - "soc": data.data.bat_all_data.data.get.soc, - "fault_state": data.data.bat_all_data.data.get.fault_state}} - except Exception: - log.exception("Fehler im Werte-Logging-Modul für Batteriespeicher-Daten") - bat_dict = {} - if data.data.bat_all_data.data.config.configured: - for bat in data.data.bat_data: - try: - bat_dict.update({bat: {"imported": data.data.bat_data[bat].data.get.imported, - "exported": data.data.bat_data[bat].data.get.exported, - "soc": data.data.bat_data[bat].data.get.soc, - "fault_state": data.data.bat_data[bat].data.get.fault_state}}) - except Exception: - log.exception("Fehler im Werte-Logging-Modul für Speicher "+str(bat)) - - try: - consumer_dict: Dict[str, Dict[str, Union[float, int]]] = {"all": { - "imported": data.data.consumer_all_data.data.get.imported, - "exported": data.data.consumer_all_data.data.get.exported, - "fault_state": data.data.consumer_all_data.data.get.fault_state}} - except Exception: - log.exception("Fehler im Werte-Logging-Modul für Verbraucher-Daten") - consumer_dict = {} - for consumer in data.data.consumer_data: - try: - consumer_dict.update({consumer: {"imported": data.data.consumer_data[consumer].data.get.imported, - "exported": data.data.consumer_data[consumer].data.get.exported, - "fault_state": data.data.consumer_data[consumer].data.get.fault_state}}) - except Exception: - log.exception("Fehler im Werte-Logging-Modul für Verbraucher "+str(consumer)) - - try: - hc_dict = {"all": { - "imported": data.data.counter_all_data.data.set.imported_home_consumption, - "fault_state": 2 if data.data.counter_all_data.data.set.invalid_home_consumption >= 3 else 0}} - except Exception: - log.exception("Fehler im Werte-Logging-Modul für Hausverbrauch") - hc_dict = {} - new_entry = { - "timestamp": current_timestamp, - "date": date, - "prices": prices_dict, - "cp": cp_dict, - "ev": ev_dict, - "counter": counter_dict, - "pv": pv_dict, - "bat": bat_dict, - "consumer": consumer_dict, - "sh": sh_log_data.sh_dict, - "hc": hc_dict - } - - return fix_values(new_entry, previous_entry) - - -def fix_values(new_entry: Dict, previous_entry: Optional[Dict]) -> Dict: - def find_and_fix_value(value_name): - if value.get(value_name) is not None: - if value[value_name] == 0: - try: - if (previous_entry[group][component][value_name] is not None and - isnan(previous_entry[group][component][value_name]) is False): - value[value_name] = previous_entry[group][component][value_name] - except KeyError: - log.exception("Es konnte kein vorheriger Wert gefunden werden.") - if previous_entry is not None: - for group, value in new_entry.items(): - if group not in ("bat", "counter", "cp", "pv", "hc"): - continue - for component, value in value.items(): - find_and_fix_value("exported") - find_and_fix_value("imported") - else: - log.warning("Keine vorherigen Werte vorhanden, um aktuelle Werte auf Plausibilität zu prüfen.") - return new_entry - - def get_names(elements: Dict, sh_names: Dict, valid_names: Optional[Dict] = None) -> Dict: """ Ermittelt die Namen der Komponenten, Fahrzeuge, Ladepunkte und SmartHome-Geräte, welche in elements vorhanden sind und gibt diese als Dictionary zurück. diff --git a/packages/helpermodules/measurement_logging/write_log_test.py b/packages/helpermodules/measurement_logging/write_log_test.py index 11421bc76b..ba8b155fd5 100644 --- a/packages/helpermodules/measurement_logging/write_log_test.py +++ b/packages/helpermodules/measurement_logging/write_log_test.py @@ -4,6 +4,9 @@ from helpermodules.measurement_logging.write_log import get_names +from helpermodules.measurement_logging.process_log_entry_builder import fix_values + + def test_get_names(daily_log_totals, monkeypatch): # setup component_names_mock = Mock(side_effect=["Speicher", "Zähler", "Wechselrichter"]) @@ -55,7 +58,7 @@ def test_fix_values(): 'timestamp': 1709109001} # execution - fixed_values = write_log.fix_values(new_entry, previous_entry) + fixed_values = fix_values(new_entry, previous_entry) # evaluation assert fixed_values == {'bat': {'all': {'exported': 0, 'imported': 2369.658, 'soc': 97}, @@ -105,7 +108,7 @@ def test_fix_values_missing_components(): 'timestamp': 1709109001} # execution - fixed_values = write_log.fix_values(new_entry, previous_entry) + fixed_values = fix_values(new_entry, previous_entry) # evaluation assert fixed_values == {'bat': {'all': {'exported': 0, 'imported': 2369.658, 'soc': 97}, diff --git a/packages/helpermodules/mosquitto_dynsec/missing_role_topics_test.py b/packages/helpermodules/mosquitto_dynsec/missing_role_topics_test.py index b1c74c086e..6b1b3b3c79 100644 --- a/packages/helpermodules/mosquitto_dynsec/missing_role_topics_test.py +++ b/packages/helpermodules/mosquitto_dynsec/missing_role_topics_test.py @@ -101,6 +101,7 @@ def _get_data_path() -> Path: 'openWB/optional/int_display/pin_active', 'openWB/optional/int_display/pin_code', 'openWB/system/datastore_version', + 'openWB/system/file_operation_version', 'openWB/system/device/+/component/+/simulation', 'openWB/system/device/+/component/+/simulation/power_present', 'openWB/system/device/+/component/+/simulation/present_exported', diff --git a/packages/helpermodules/setdata.py b/packages/helpermodules/setdata.py index 8cb2fed9d3..665ff72fb9 100644 --- a/packages/helpermodules/setdata.py +++ b/packages/helpermodules/setdata.py @@ -486,7 +486,8 @@ def process_chargepoint_topic(self, msg: mqtt.MQTTMessage): else: self._validate_value(msg, float, [(float("-inf"), 0), (6, 32), (0, 0)]) elif "/set/required_power" in msg.topic: - self._validate_value(msg, float, [(0, float("inf"))]) + # Don't accept NaN + self._validate_value(msg, float, [(float("-inf"), float("inf"))]) elif "/set/phases_to_use" in msg.topic: self._validate_value(msg, int, [(0, 3)]) elif ("/set/manual_lock" in msg.topic or @@ -1068,6 +1069,7 @@ def process_system_topic(self, msg: mqtt.MQTTMessage): "openWB/set/system/perform_update" in msg.topic or "openWB/set/system/wizard_done" in msg.topic or "openWB/set/system/update_in_progress" in msg.topic or + "openWB/set/system/log_data_ready" in msg.topic or "openWB/set/system/backup_cloud/backup_before_update" in msg.topic or "openWB/set/system/installAssistantDone" in msg.topic or "openWB/set/system/dataprotection_acknowledged" in msg.topic or @@ -1082,6 +1084,8 @@ def process_system_topic(self, msg: mqtt.MQTTMessage): self._validate_value(msg, float) elif "openWB/set/system/datastore_version" in msg.topic: self._validate_value(msg, int, [(0, UpdateConfig.DATASTORE_VERSION)], collection=list) + elif "openWB/set/system/file_operation_version" in msg.topic: + self._validate_value(msg, int, [(0, UpdateConfig.FILE_OPERATION_VERSION)], collection=list) elif "openWB/set/system/GetRemoteSupport" in msg.topic: # Server-Topic enthält kein json-Payload. payload = msg.payload.decode("utf-8") diff --git a/packages/helpermodules/subdata.py b/packages/helpermodules/subdata.py index a115217f78..6ed8592708 100644 --- a/packages/helpermodules/subdata.py +++ b/packages/helpermodules/subdata.py @@ -1247,6 +1247,9 @@ def process_consumer_topic(self, client: mqtt.Client, var: Dict[str, Consumer], var.pop("consumer"+index) else: if f"consumer{index}" not in var: + if re.search( + r"openWB/consumer/[0-9]+/(module|config|usage|extra_meter)$", msg.topic) is None: + return var[f"consumer{index}"] = Consumer(int(index)) if re.search("openWB/consumer/[0-9]+/module$", msg.topic) is not None: consumer_config = decode_payload(msg.payload) @@ -1274,9 +1277,23 @@ def process_consumer_topic(self, client: mqtt.Client, var: Dict[str, Consumer], elif re.search("openWB/consumer/[0-9]+/set", msg.topic) is not None: self.set_json_payload_class(var["consumer"+index].data.set, msg) elif re.search("openWB/consumer/[0-9]+/extra_meter", msg.topic) is not None: + old_extra_meter = var[f"consumer{index}"].data.extra_meter self.set_json_payload_class(var[f"consumer{index}"].data, msg) + if self.event_subdata_initialized.is_set() and old_extra_meter != var[ + f"consumer{index}"].data.extra_meter: + if self.counter_all_data.update_linked_counter_hierarchy( + int(index), + old_extra_meter, + var[f"consumer{index}"].data.extra_meter): + Pub().pub("openWB/set/counter/get/hierarchy", + self.counter_all_data.data.get.hierarchy) elif re.search("openWB/consumer/[0-9]+/usage$", msg.topic) is not None: - var[f"consumer{index}"].data.usage = dataclass_from_dict(Usage, decode_payload(msg.payload)) + usage = dataclass_from_dict(Usage, decode_payload(msg.payload)) + var[f"consumer{index}"].data.usage = usage + if (self.event_subdata_initialized.is_set() and + self.counter_all_data.update_consumer_loadmanagement_prio(int(index), usage.type)): + Pub().pub("openWB/set/counter/get/loadmanagement_prios", + self.counter_all_data.data.get.loadmanagement_prios) elif re.search("openWB/consumer/[0-9]+/control_parameter/", msg.topic) is not None: if re.search("openWB/consumer/[0-9]+/control_parameter/limit", msg.topic) is not None: payload = decode_payload(msg.payload) diff --git a/packages/helpermodules/subdata_test.py b/packages/helpermodules/subdata_test.py new file mode 100644 index 0000000000..6d610bde64 --- /dev/null +++ b/packages/helpermodules/subdata_test.py @@ -0,0 +1,192 @@ +import json +from typing import Dict, List +from unittest.mock import Mock + +import pytest + +from control.consumer.consumer import Consumer +from control.consumer.usage import ConsumerUsage +from control.counter_all.counter_all import CounterAll +from helpermodules.subdata import SubData + + +@pytest.mark.parametrize( + "usage_type, loadmanagement_prios, expected_loadmanagement_prios, expected_changed", + [ + pytest.param( + ConsumerUsage.SUSPENDABLE_TUNABLE, + [], + [{"type": "consumer", "id": 2}], + True, + id="add controllable consumer", + ), + pytest.param( + ConsumerUsage.CONTINUOUS, + [{"type": "group", "label": "Gruppe", "children": [{"type": "consumer", "id": 2}]}], + [{"type": "group", "label": "Gruppe", "children": [{"type": "consumer", "id": 2}]}], + False, + id="do not duplicate grouped consumer", + ), + pytest.param( + ConsumerUsage.METER_ONLY, + [{"type": "group", "label": "Gruppe", "children": [{"type": "consumer", "id": 2}]}], + [], + True, + id="remove meter-only consumer from group", + ), + pytest.param( + ConsumerUsage.METER_ONLY, + [], + [], + False, + id="ignore absent meter-only consumer", + ), + pytest.param( + ConsumerUsage.SELF_CONTROLLED, + [{"type": "consumer", "id": 2}], + [], + True, + id="remove self-controlled consumer", + ), + ], +) +def test_process_consumer_usage_updates_loadmanagement_prios(usage_type: ConsumerUsage, + loadmanagement_prios: List[Dict], + expected_loadmanagement_prios: List[Dict], + expected_changed: bool, + monkeypatch: pytest.MonkeyPatch, + mock_pub: Mock): + subdata = SubData.__new__(SubData) + subdata.event_subdata_initialized = Mock() + subdata.event_subdata_initialized.is_set.return_value = True + counter_all_data = CounterAll() + counter_all_data.data.get.loadmanagement_prios = loadmanagement_prios + monkeypatch.setattr(SubData, "counter_all_data", counter_all_data) + consumers = {"consumer2": Consumer(2)} + msg = Mock( + topic="openWB/consumer/2/usage", + payload=json.dumps({"type": usage_type.value}).encode("utf-8"), + ) + + subdata.process_consumer_topic(Mock(), consumers, msg) + + assert consumers["consumer2"].data.usage.type == usage_type + assert counter_all_data.data.get.loadmanagement_prios == expected_loadmanagement_prios + if expected_changed: + mock_pub.pub.assert_called_once_with( + "openWB/set/counter/get/loadmanagement_prios", expected_loadmanagement_prios) + else: + mock_pub.pub.assert_not_called() + + +def test_process_consumer_usage_does_not_update_loadmanagement_prios_on_startup(monkeypatch: pytest.MonkeyPatch, + mock_pub: Mock): + subdata = SubData.__new__(SubData) + subdata.event_subdata_initialized = Mock() + subdata.event_subdata_initialized.is_set.return_value = False + counter_all_data = CounterAll() + counter_all_data.data.get.loadmanagement_prios = [{"type": "consumer", "id": 2}] + monkeypatch.setattr(SubData, "counter_all_data", counter_all_data) + consumers = {"consumer2": Consumer(2)} + msg = Mock( + topic="openWB/consumer/2/usage", + payload=json.dumps({"type": ConsumerUsage.METER_ONLY.value}).encode("utf-8"), + ) + + subdata.process_consumer_topic(Mock(), consumers, msg) + + assert consumers["consumer2"].data.usage.type == ConsumerUsage.METER_ONLY + assert counter_all_data.data.get.loadmanagement_prios == [{"type": "consumer", "id": 2}] + mock_pub.pub.assert_not_called() + + +def test_process_consumer_extra_meter_removes_linked_counter_from_hierarchy(monkeypatch: pytest.MonkeyPatch, + mock_pub: Mock): + subdata = SubData.__new__(SubData) + subdata.event_subdata_initialized = Mock() + subdata.event_subdata_initialized.is_set.return_value = True + counter_all_data = CounterAll() + counter_all_data.data.get.hierarchy = [{ + "id": 0, + "type": "counter", + "children": [ + {"id": 2, "type": "consumer", "children": []}, + {"id": 5, "type": "counter", "children": []}, + ], + }] + monkeypatch.setattr(SubData, "counter_all_data", counter_all_data) + consumers = {"consumer2": Consumer(2)} + msg = Mock(topic="openWB/consumer/2/extra_meter", payload=b"5") + + subdata.process_consumer_topic(Mock(), consumers, msg) + + assert consumers["consumer2"].data.extra_meter == 5 + assert counter_all_data.data.get.hierarchy == [{ + "id": 0, + "type": "counter", + "children": [{"id": 2, "type": "consumer", "children": []}], + }] + mock_pub.pub.assert_called_once_with("openWB/set/counter/get/hierarchy", counter_all_data.data.get.hierarchy) + + +def test_process_consumer_extra_meter_adds_unlinked_counter_to_hierarchy(monkeypatch: pytest.MonkeyPatch, + mock_pub: Mock): + subdata = SubData.__new__(SubData) + subdata.event_subdata_initialized = Mock() + subdata.event_subdata_initialized.is_set.return_value = True + counter_all_data = CounterAll() + counter_all_data.data.get.hierarchy = [{ + "id": 0, + "type": "counter", + "children": [{"id": 2, "type": "consumer", "children": []}], + }] + monkeypatch.setattr(SubData, "counter_all_data", counter_all_data) + consumers = {"consumer2": Consumer(2)} + consumers["consumer2"].data.extra_meter = 5 + msg = Mock(topic="openWB/consumer/2/extra_meter", payload=b"null") + + subdata.process_consumer_topic(Mock(), consumers, msg) + + assert consumers["consumer2"].data.extra_meter is None + assert counter_all_data.data.get.hierarchy == [{ + "id": 0, + "type": "counter", + "children": [ + {"id": 2, "type": "consumer", "children": []}, + {"id": 5, "type": "counter", "children": []}, + ], + }] + mock_pub.pub.assert_called_once_with("openWB/set/counter/get/hierarchy", counter_all_data.data.get.hierarchy) + + +def test_process_consumer_extra_meter_switches_linked_counter_in_hierarchy(monkeypatch: pytest.MonkeyPatch, + mock_pub: Mock): + subdata = SubData.__new__(SubData) + subdata.event_subdata_initialized = Mock() + subdata.event_subdata_initialized.is_set.return_value = True + counter_all_data = CounterAll() + counter_all_data.data.get.hierarchy = [{ + "id": 0, + "type": "counter", + "children": [ + {"id": 2, "type": "consumer", "children": []}, + {"id": 6, "type": "counter", "children": []}, + ], + }] + monkeypatch.setattr(SubData, "counter_all_data", counter_all_data) + consumers = {"consumer2": Consumer(2)} + consumers["consumer2"].data.extra_meter = 5 + msg = Mock(topic="openWB/consumer/2/extra_meter", payload=b"6") + + subdata.process_consumer_topic(Mock(), consumers, msg) + + assert consumers["consumer2"].data.extra_meter == 6 + assert counter_all_data.data.get.hierarchy == [{ + "id": 0, + "type": "counter", + "children": [ + {"id": 2, "type": "consumer", "children": []}, + {"id": 5, "type": "counter", "children": []}, + ], + }] + mock_pub.pub.assert_called_once_with("openWB/set/counter/get/hierarchy", counter_all_data.data.get.hierarchy) diff --git a/packages/helpermodules/update_config.py b/packages/helpermodules/update_config.py index bfcf34fc25..862f29bb68 100644 --- a/packages/helpermodules/update_config.py +++ b/packages/helpermodules/update_config.py @@ -8,10 +8,13 @@ from pathlib import Path import re import time +import sys +import subprocess from typing import List, Optional from paho.mqtt.client import Client as MqttClient, MQTTMessage from control.chargemode import Chargemode +from control.consumer.usage import NOT_CONTROLLED from control.limiting_value import LoadmanagementLimit from control.chargepoint.chargepoint_template import get_chargepoint_template_default @@ -58,7 +61,9 @@ class UpdateConfig: - DATASTORE_VERSION = 148 + DATASTORE_VERSION = 151 + + FILE_OPERATION_VERSION = 0 valid_topic = [ "^openWB/bat/config/bat_control_activated$", @@ -569,10 +574,12 @@ class UpdateConfig: "^openWB/system/device/[0-9]+/component/[0-9]+/simulation/timestamp_present$", "^openWB/system/device/[0-9]+/config$", "^openWB/system/device/module_update_completed$", + "^openWB/system/file_operation_version$", "^openWB/system/hostname$", "^openWB/system/io/[0-9]+/config$", "^openWB/system/ip_address$", "^openWB/system/lastlivevaluesJson$", + "^openWB/system/log_data_ready$", "^openWB/system/mac_address$", "^openWB/system/mqtt/bridge/[0-9]+$", "^openWB/system/mqtt/valid_partner_ids$", @@ -595,6 +602,7 @@ class UpdateConfig: "^openWB/system/security/access/LoadManagementConfiguration$", "^openWB/system/security/access/ChargePointInstallation$", "^openWB/system/security/access/VehicleConfiguration$", + "^openWB/system/security/access/ConsumerConfiguration$", "^openWB/system/security/access/IoConfiguration$", "^openWB/system/security/access/LegacySmartHomeConfiguration$", "^openWB/system/security/access/InstallAssistant$", @@ -737,6 +745,7 @@ class UpdateConfig: ("openWB/system/security/access/LoadManagementConfiguration", True), ("openWB/system/security/access/ChargePointInstallation", True), ("openWB/system/security/access/VehicleConfiguration", True), + ("openWB/system/security/access/ConsumerConfiguration", True), ("openWB/system/security/access/IoConfiguration", True), ("openWB/system/security/access/LegacySmartHomeConfiguration", True), ("openWB/system/security/access/InstallAssistant", True), @@ -775,6 +784,7 @@ def update(self): try: # erst breaking changes auflösen, sonst sind alte Topics schon gelöscht self.__solve_breaking_changes() + self.__solve_breaking_changes_filesystem() self.__remove_outdated_topics() self._remove_invalid_topics() self.__pub_missing_defaults() @@ -875,6 +885,67 @@ def __solve_breaking_changes(self) -> None: pub_system_message( {}, "Fehler bei der Aktualisierung der Konfiguration des Brokers.", MessageType.ERROR) + def __solve_breaking_changes_filesystem(self) -> None: + """Führt dateisystembezogene Migrationen anhand der file_operation_version aus.""" + file_operation_version = decode_payload(self.all_received_topics.get("openWB/system/file_operation_version")) + if file_operation_version is None: + # Neues Topic (z.B. bei Upgrade von älteren Versionen): alle File-Operation-Upgrades ausführen. + file_operation_version = [] + self.__update_topic("openWB/system/file_operation_version", file_operation_version) + elif isinstance(file_operation_version, int): + # Legacy-Format (int): bereits ausgeführte Upgrades als Liste abbilden. + file_operation_version = list(range(file_operation_version)) + self.__update_topic("openWB/system/file_operation_version", file_operation_version) + + log.debug(f"current file operation version: {file_operation_version}") + log.debug(f"target file operation version: {self.FILE_OPERATION_VERSION}") + for version in list(range(self.FILE_OPERATION_VERSION+1)): + try: + operation_required = version not in file_operation_version + if version == 0: + # Version 0 erneut ausführen, solange die Hintergrund-Generierung der Tages-/Monatssummen + # noch nicht erfolgreich abgeschlossen ist (Flag ist nicht True). + log_totals_generation_finished = decode_payload( + self.all_received_topics.get("openWB/system/log_data_ready")) + operation_required = operation_required or log_totals_generation_finished is not True + + if operation_required: + log.debug(f"upgrading File Operation version '{version}'") + getattr(self, f"upgrade_file_operation_{version}")() + except AttributeError: + log.error(f"missing upgrade function! '{version}'") + except Exception: + log.exception("Fehler bei der Aktualisierung des Brokers.") + pub_system_message( + {}, "Fehler bei der Aktualisierung der Konfiguration des Brokers.", MessageType.ERROR) + + def upgrade_file_operation_0(self) -> None: + """ + Generiere die Totals-Summen für Tage und Monate + """ + + self.__update_topic("openWB/system/log_data_ready", False) + try: + _generate_totals_subprocess = subprocess.Popen( + [ + sys.executable, + "-m", + "helpermodules.measurement_logging.generate_totals_subprocess" + ], + cwd="/var/www/html/openWB/packages", + start_new_session=True, + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + close_fds=True + ) + + log.debug("generate_totals_subprocess gestartet, PID: %s", _generate_totals_subprocess.pid) + self._append_file_operation_version(0) + except Exception: + _generate_totals_subprocess = None + log.exception("Fehler beim Starten des generate_totals_subprocess.") + def _loop_all_received_topics(self, callback) -> None: modified_topics = {} for topic, payload in self.all_received_topics.items(): @@ -893,6 +964,12 @@ def _append_datastore_version(self, version: int) -> None: datastore_versions.append(version) self.__update_topic("openWB/system/datastore_version", datastore_versions) + def _append_file_operation_version(self, version: int) -> None: + file_operation_versions = decode_payload(self.all_received_topics.get("openWB/system/file_operation_version")) + if version not in file_operation_versions: + file_operation_versions.append(version) + self.__update_topic("openWB/system/file_operation_version", file_operation_versions) + def upgrade_datastore_0(self) -> None: def upgrade(topic: str, payload) -> Optional[dict]: modified_topics = {} @@ -3864,3 +3941,110 @@ def upgrade(topic: str, payload) -> Optional[dict]: return {topic: NO_MODULE} self._loop_all_received_topics(upgrade) self._append_datastore_version(148) + + def upgrade_datastore_149(self) -> None: + """Verbraucher ohne Lastmanagement aus der Prioritätensteuerung entfernen.""" + not_controlled_usage_types = {usage_type.value for usage_type in NOT_CONTROLLED} + not_controlled_consumers = { + int(get_index(topic)) + for topic, payload in self.all_received_topics.items() + if re.search(r"^openWB/consumer/[0-9]+/usage$", topic) is not None + and decode_payload(payload).get("type") in not_controlled_usage_types + } + + def remove_not_controlled_consumers(entries: list) -> None: + for entry in entries.copy(): + if entry.get("type") == "consumer" and entry.get("id") in not_controlled_consumers: + entries.remove(entry) + elif entry.get("type") == "group": + children = entry.get("children", []) + had_children = bool(children) + remove_not_controlled_consumers(children) + if had_children and not children: + entries.remove(entry) + + topic = "openWB/counter/get/loadmanagement_prios" + if topic in self.all_received_topics: + loadmanagement_prios = decode_payload(self.all_received_topics[topic]) + migrated_loadmanagement_prios = copy.deepcopy(loadmanagement_prios) + remove_not_controlled_consumers(migrated_loadmanagement_prios) + if migrated_loadmanagement_prios != loadmanagement_prios: + self.__update_topic(topic, migrated_loadmanagement_prios) + self._append_datastore_version(149) + + def upgrade_datastore_150(self) -> None: + """Fixed-Hours-Wochentage vom alten Schema (So=0..Sa=6) auf Python weekday() (Mo=0..So=6) migrieren.""" + + def _shift_weekday(weekday: int) -> int: + return (weekday + 6) % 7 + + def upgrade(topic: str, payload) -> Optional[dict]: + if ("openWB/optional/ep/flexible_tariff/provider" == topic or + "openWB/optional/ep/grid_fee/provider" == topic): + provider = decode_payload(payload) + if provider.get("type") != "fixed_hours": + return None + config = provider.get("configuration", {}) + tariffs = config.get("tariffs", []) + changed = False + for tariff in tariffs: + active_times = tariff.get("active_times", {}) + weekdays = active_times.get("weekdays") + if isinstance(weekdays, list): + converted = [] + for weekday in weekdays: + try: + weekday_int = int(weekday) + except (TypeError, ValueError): + converted.append(weekday) + continue + converted.append(_shift_weekday(weekday_int)) + if converted != weekdays: + active_times["weekdays"] = converted + changed = True + if changed: + return {topic: provider} + return None + + self._loop_all_received_topics(upgrade) + self._append_datastore_version(150) + + def upgrade_datastore_151(self): + """Bereits verknüpfte Verbraucher-Zähler aus der Hierarchie entfernen.""" + hierarchy_topic = "openWB/counter/get/hierarchy" + hierarchy = decode_payload(self.all_received_topics.get(hierarchy_topic)) + if not isinstance(hierarchy, list) or not hierarchy: + self._append_datastore_version(151) + return + + linked_counter_ids = { + int(extra_meter_id) + for topic, payload in self.all_received_topics.items() + if re.search(r"^openWB/consumer/[0-9]+/extra_meter$", topic) is not None + for extra_meter_id in [decode_payload(payload)] + if extra_meter_id is not None + } + + if not linked_counter_ids: + self._append_datastore_version(151) + return + + migrated_hierarchy = copy.deepcopy(hierarchy) + _counter_all = counter_all.CounterAll() + _counter_all.data.get.hierarchy = migrated_hierarchy + hierarchy_changed = False + + for linked_counter_id in linked_counter_ids: + counter_entry = _counter_all.get_entry_of_element(linked_counter_id) + if not counter_entry or counter_entry.get("type") != ComponentType.COUNTER.value: + continue + parent_entry = _counter_all.get_entry_of_parent(linked_counter_id) + if not parent_entry or parent_entry.get("type") != ComponentType.COUNTER.value: + continue + _counter_all.hierarchy_remove_item(linked_counter_id) + hierarchy_changed = True + + if hierarchy_changed: + self.__update_topic(hierarchy_topic, _counter_all.data.get.hierarchy) + + self._append_datastore_version(151) diff --git a/packages/helpermodules/update_config_test.py b/packages/helpermodules/update_config_test.py index cad4b7c060..1938dcc7ea 100644 --- a/packages/helpermodules/update_config_test.py +++ b/packages/helpermodules/update_config_test.py @@ -363,3 +363,154 @@ def test_upgrade_datastore_142_ev_chargemode_conversion(ev0_prio: bool, assert uc.all_received_topics["openWB/counter/get/loadmanagement_prios"] == expected assert uc.all_received_topics["openWB/system/datastore_version"] == [131, 132, 142] assert mock_pub.pub.call_count == 2 # einmal publishen für Upgrade der Datastore-Version + + +def test_upgrade_datastore_149_removes_not_controlled_consumers(mock_pub: Mock): + uc = UpdateConfig() + uc.all_received_topics = { + "openWB/consumer/1/usage": {"type": "continuous"}, + "openWB/consumer/2/usage": {"type": "meter_only"}, + "openWB/consumer/4/usage": {"type": "self_controlled"}, + "openWB/counter/get/loadmanagement_prios": [ + {"type": "vehicle", "id": 0}, + { + "type": "group", + "label": "Verbraucher", + "children": [ + {"type": "consumer", "id": 1}, + {"type": "consumer", "id": 2}, + {"type": "consumer", "id": 4}, + ], + }, + {"type": "consumer", "id": 3}, + ], + "openWB/system/datastore_version": list(range(149)), + } + + uc.upgrade_datastore_149() + + assert uc.all_received_topics["openWB/counter/get/loadmanagement_prios"] == [ + {"type": "vehicle", "id": 0}, + { + "type": "group", + "label": "Verbraucher", + "children": [{"type": "consumer", "id": 1}], + }, + {"type": "consumer", "id": 3}, + ] + assert uc.all_received_topics["openWB/system/datastore_version"] == list(range(150)) + assert mock_pub.pub.call_count == 2 + + +def test_upgrade_datastore_150_migrates_fixed_hours_weekdays(mock_pub): + uc = UpdateConfig() + uc.all_received_topics = { + "openWB/optional/ep/flexible_tariff/provider": { + "type": "fixed_hours", + "configuration": { + "tariffs": [ + { + "name": "weekdays", + "price": 0.1, + "active_times": { + "dates": [["01-01", "31-12"]], + "times": [["00:00", "24:00"]], + "weekdays": [1, 2, 3, 4, 5], + }, + }, + { + "name": "weekend", + "price": 0.2, + "active_times": { + "dates": [["01-01", "31-12"]], + "times": [["00:00", "24:00"]], + "weekdays": [0, 6], + }, + }, + ] + }, + }, + "openWB/optional/ep/grid_fee/provider": { + "type": "fixed_hours", + "configuration": { + "tariffs": [ + { + "name": "all-days", + "price": 0.3, + "active_times": { + "dates": [["01-01", "31-12"]], + "times": [["00:00", "24:00"]], + "weekdays": [0, 1, 2, 3, 4, 5, 6], + }, + } + ] + }, + }, + "openWB/system/datastore_version": [149], + } + + uc.upgrade_datastore_150() + + flexible_tariffs = uc.all_received_topics["openWB/optional/ep/flexible_tariff/provider"]["configuration"][ + "tariffs" + ] + assert flexible_tariffs[0]["active_times"]["weekdays"] == [0, 1, 2, 3, 4] + assert flexible_tariffs[1]["active_times"]["weekdays"] == [6, 5] + + grid_fee_tariffs = uc.all_received_topics["openWB/optional/ep/grid_fee/provider"]["configuration"]["tariffs"] + assert grid_fee_tariffs[0]["active_times"]["weekdays"] == [6, 0, 1, 2, 3, 4, 5] + + assert uc.all_received_topics["openWB/system/datastore_version"] == [149, 150] + + updated_topics = [call.args[0] for call in mock_pub.pub.call_args_list] + assert "openWB/optional/ep/flexible_tariff/provider" in updated_topics + assert "openWB/optional/ep/grid_fee/provider" in updated_topics + assert "openWB/system/datastore_version" in updated_topics + + +def test_upgrade_datastore_151_removes_linked_extra_meter_counters_from_hierarchy(mock_pub: Mock): + uc = UpdateConfig() + uc.all_received_topics = { + "openWB/consumer/2/extra_meter": 5, + "openWB/consumer/3/extra_meter": None, + "openWB/counter/get/hierarchy": [{ + "id": 0, + "type": "counter", + "children": [ + {"id": 2, "type": "consumer", "children": []}, + {"id": 5, "type": "counter", "children": []}, + {"id": 6, "type": "counter", "children": []}, + ], + }], + "openWB/system/datastore_version": list(range(151)), + } + + uc.upgrade_datastore_151() + + assert uc.all_received_topics["openWB/counter/get/hierarchy"] == [{ + "id": 0, + "type": "counter", + "children": [ + {"id": 2, "type": "consumer", "children": []}, + {"id": 6, "type": "counter", "children": []}, + ], + }] + assert uc.all_received_topics["openWB/system/datastore_version"] == list(range(152)) + assert mock_pub.pub.call_count == 2 + + +@pytest.mark.parametrize("file_operation_version, finished, expected_calls", [ + ([], False, 1), # erster Start + ([0], True, 0), # bereits fertig -> kein Neustart + ([0], False, 1), # angefangen, aber nicht fertig -> Neustart +]) +def test_file_operation_0_start_behavior(file_operation_version, finished, expected_calls): + update_config = UpdateConfig() + update_config.all_received_topics = { + "openWB/system/file_operation_version": file_operation_version, + "openWB/system/log_data_ready": finished + } + + with patch.object(update_config, "upgrade_file_operation_0") as upgrade_mock: + update_config._UpdateConfig__solve_breaking_changes_filesystem() + assert upgrade_mock.call_count == expected_calls diff --git a/packages/main.py b/packages/main.py index d7d63d337f..a9ad7ae912 100755 --- a/packages/main.py +++ b/packages/main.py @@ -27,7 +27,7 @@ from helpermodules.changed_values_handler import ChangedValuesContext from helpermodules.mosquitto_dynsec.mosquitto_dynsec import check_roles_at_start from helpermodules.measurement_logging.update_yields import update_daily_yields, update_pv_monthly_yearly_yields -from helpermodules.measurement_logging.write_log import LogType, save_log +from helpermodules.measurement_logging.write_log import save_log from helpermodules.modbusserver import start_modbus_server from helpermodules.pub import Pub from modules import configuration, loadvars, update_soc @@ -37,6 +37,7 @@ from modules.utils import wait_for_module_update_completed from smarthome.smarthome import readmq, smarthome_handler +from helpermodules.measurement_logging.process_log import save_daily_source_totals, save_monthly_source_totals class HandlerAlgorithm: def __init__(self): @@ -180,9 +181,11 @@ def handler5MinAlgorithm(self): """ try: with ChangedValuesContext(loadvars_.event_module_update_completed): - totals = save_log(LogType.DAILY) - update_daily_yields(totals) - update_pv_monthly_yearly_yields() + entries = save_log() + daily_totals = update_daily_yields(entries) + if daily_totals is not None: + update_pv_monthly_yearly_yields(daily_totals) + for cp in data.data.cp_data.values(): calc_energy_costs(cp) data.data.general_data.grid_protection() @@ -230,7 +233,15 @@ def handler5Min(self): @__with_handler_lock(error_threshold=60) def handler_midnight(self): try: - save_log(LogType.MONTHLY) + today = timecheck.create_timestamp_YYYYMMDD() + previous_day = timecheck.get_relative_date_string(today, day_offset=-1) + save_daily_source_totals(previous_day) + + prev_month = timecheck.get_relative_date_string(today, month_offset=-1)[:6] + # Neuer Monat hat angefangen, daher Monats Totals speichern + if today[6:8] == "01": + save_monthly_source_totals(prev_month, None, saving=True) + thread_errors_path = Path(Path(__file__).resolve().parents[1]/"ramdisk"/"thread_errors.log") with thread_errors_path.open("w") as f: f.write("") diff --git a/packages/modules/chargepoints/external_openwb/chargepoint_module.py b/packages/modules/chargepoints/external_openwb/chargepoint_module.py index c0b162e4c8..db60fb8d5b 100644 --- a/packages/modules/chargepoints/external_openwb/chargepoint_module.py +++ b/packages/modules/chargepoints/external_openwb/chargepoint_module.py @@ -66,12 +66,15 @@ def on_message(client, userdata, message): received_topics.update({message.topic: decode_payload(message.payload)}) received_topics = {} + # Höheres Timeout als der Default: muss zuverlässig alle ~20 retained Topics über eine + # echte Netzwerkverbindung zu einer ggf. gerade stark ausgelasteten Gegenstelle einsammeln + # (zB während einer laufenden Ladung), nicht nur ein oder zwei lokale Werte. BrokerClient(f"subscribeSeriesChargepoint{self.config.id}", on_connect, on_message, host=self.config.configuration.ip_address, port=1886 if self.config.configuration.ip_address == "localhost" else 1883 - ).start_finite_loop() + ).start_finite_loop(timeout=2) if received_topics: log.debug(f"Empfange MQTT Daten für Ladepunkt {self.config.id}: {received_topics}") @@ -101,16 +104,17 @@ def on_message(client, userdata, message): current_branch=parse_received_topics("current_branch"), current_commit=parse_received_topics("current_commit") ) - self.store.set(chargepoint_state) - if received_topics[f"{topic_prefix}fault_state"] == 2: - self.fault_state.error(received_topics[f"{topic_prefix}fault_str"]) - elif received_topics[f"{topic_prefix}fault_state"] == 1: - self.fault_state.warning(received_topics[f"{topic_prefix}fault_str"]) - except KeyError: - if received_topics[f"{topic_prefix}fault_state"] == 2: - self.fault_state.error(received_topics[f"{topic_prefix}fault_str"]) - else: - raise KeyError("Es wurden nicht alle notwendigen Daten empfangen.") + except KeyError as e: + raise KeyError(f"Es wurden nicht alle notwendigen Daten empfangen: {e}") + self.store.set(chargepoint_state) + + fault_state = received_topics.get(f"{topic_prefix}fault_state") + if fault_state == 2: + self.fault_state.error(received_topics.get( + f"{topic_prefix}fault_str", "Fehlerstatus vom internen Ladepunkt")) + elif fault_state == 1: + self.fault_state.warning(received_topics.get( + f"{topic_prefix}fault_str", "Warnung vom internen Ladepunkt")) else: self.fault_state.warning(f"Keine MQTT-Daten für Ladepunkt {self.config.name} empfangen. Noch keine " "Daten nach dem Start oder Ladepunkt nicht erreichbar.") diff --git a/packages/modules/common/component_type.py b/packages/modules/common/component_type.py index 8ae99c397b..2f8cb58a65 100644 --- a/packages/modules/common/component_type.py +++ b/packages/modules/common/component_type.py @@ -63,14 +63,28 @@ def type_topic_mapping_comp(component_type: str) -> str: def component_type_to_readable_text(component_type: ComponentType): - if component_type == ComponentType.BAT: + if component_type == ComponentType.BACKUP_CLOUD: + return "Backup Cloud" + elif component_type == ComponentType.BAT: return "Speicher" - elif component_type == ComponentType.COUNTER: - return "Zähler" elif component_type == ComponentType.CHARGEPOINT: return "Ladepunkt" + elif component_type == ComponentType.CONSUMER: + return "Verbraucher" + elif component_type == ComponentType.COUNTER: + return "Zähler" + elif component_type == ComponentType.FLEXIBLE_TARIFF: + return "Dynamischer Tarif" + elif component_type == ComponentType.GRID_FEE: + return "Netzentgelt" elif component_type == ComponentType.INVERTER: return "Wechselrichter" + elif component_type == ComponentType.IO: + return "IO-Geräte" + elif component_type == ComponentType.VEHICLE: + return "Fahrzeug" + else: + return "Unbekannter Komponenten-Typ" class ComponentDescriptor: diff --git a/packages/modules/common/fault_state.py b/packages/modules/common/fault_state.py index 854787f104..499725ac52 100644 --- a/packages/modules/common/fault_state.py +++ b/packages/modules/common/fault_state.py @@ -1,6 +1,7 @@ import logging import traceback -from typing import Optional, Callable, TypeVar +from types import TracebackType +from typing import Optional, Callable, Type, TypeVar from helpermodules import exceptions from helpermodules.pub import Pub @@ -87,4 +88,30 @@ def from_exception(self, exception: Optional[Exception] = None) -> None: exception) +class FaultStateContext: + def __init__(self, fault_state: FaultState, update_always: bool = True, reraise: bool = False) -> None: + self.__fault_state = fault_state + self.update_always = update_always + self.reraise = reraise + + def __enter__(self) -> None: + if self.update_always: + self.__fault_state.no_error() + return None + + def __exit__(self, + exc_type: Optional[Type[BaseException]], + exc_value: Optional[BaseException], + traceback: Optional[TracebackType]) -> bool: + if exc_value is not None and not isinstance(exc_value, Exception): + return False + if isinstance(exc_value, Exception): + self.__fault_state.from_exception(exc_value) + elif self.update_always is False and self.__fault_state.fault_state == 0: + # Fehlerstatus nicht überschreiben + return True + self.__fault_state.store_error() + return self.reraise is False or exc_value is None + + T_C = TypeVar("T_C", bound=Callable) diff --git a/packages/modules/common/utils/peak_filter.py b/packages/modules/common/utils/peak_filter.py index 0ef0cc1af3..bfb26cfbad 100644 --- a/packages/modules/common/utils/peak_filter.py +++ b/packages/modules/common/utils/peak_filter.py @@ -1,6 +1,6 @@ import logging from control import data -from typing import Optional +from typing import Optional, Tuple from modules.common.fault_state import FaultState from modules.common.component_type import ComponentType @@ -64,12 +64,12 @@ def check_imported_exported( control_interval = data.data.general_data.data.control_interval allowed_deviation = 2 * (control_interval / 3600) * max_power - imp = self.check_total_energy(imported, self.imported, allowed_deviation, max_energy) - self.imported = imported + checked_imp, prev_imp = self.check_total_energy(imported, self.imported, allowed_deviation, max_energy) + self.imported = prev_imp - exp = self.check_total_energy(exported, self.exported, allowed_deviation, max_energy) - self.exported = exported - return imp, exp + checked_exp, prev_exp = self.check_total_energy(exported, self.exported, allowed_deviation, max_energy) + self.exported = prev_exp + return checked_imp, checked_exp return imported, exported def check_total_energy( @@ -78,7 +78,7 @@ def check_total_energy( previous_total_energy: Optional[float], allowed_deviation: float, max_energy: float - ) -> Optional[float]: + ) -> Tuple[Optional[float], Optional[float]]: if total_energy is not None: if total_energy > max_energy: log.debug(f"PeakFilter: Unplausibler Zählerwert: {total_energy / 1000}kWh. " @@ -88,14 +88,37 @@ def check_total_energy( self.fault_state.warning("PeakFilter: Vorheriger Wert None (Startup), " f"aktueller Zählerwert: {total_energy / 1000 }kWh. " "Warte einen Regelintervall.") - elif (allowed_deviation > 0 and - ((total_energy - previous_total_energy) > allowed_deviation or - (total_energy - previous_total_energy) < 0)): + elif allowed_deviation > 0 and (total_energy - previous_total_energy) > allowed_deviation: log.debug(f"PeakFilter: Unplausibler Zählerwert: {total_energy / 1000}kWh. " f"Differenz zum vorherigen Wert: {total_energy - previous_total_energy}Wh. " f"erlaubte Differenz: {round(allowed_deviation, 2)}Wh.") + elif (total_energy - previous_total_energy) < 0: + if previous_total_energy > max_energy: + log.debug("PeakFilter: Erholung nach unplausibel hohem Zählerwert erkannt. " + f"Vorheriger Wert: {previous_total_energy / 1000}kWh über dem 50-Jahres-Limit " + f"({max_energy / 1000}kWh). Aktueller Wert {total_energy / 1000}kWh wird " + "verworfen und als neue Vergleichsbasis verwendet.") + checked_energy = None + prev_energy = total_energy + return checked_energy, prev_energy + log.debug(f"PeakFilter: Unplausibler Rückwärtssprung: {total_energy / 1000}kWh. " + f"Vorherigen Wert: {previous_total_energy / 1000}kWh. " + "Höchststand beibehalten.") + checked_energy = previous_total_energy + prev_energy = previous_total_energy + return checked_energy, prev_energy else: log.debug(f"PeakFilter: Zählerwert: {total_energy}Wh innerhalb der zulässigen Grenzen. " f"Differenz zum vorherigen Wert: {total_energy - previous_total_energy}Wh.") - return total_energy - return None + checked_energy = total_energy + prev_energy = total_energy + return checked_energy, prev_energy + # wenn der Peak-Filter greift, Zählerstand nicht aktualisieren (None). + # Zählerstand merken, damit immer nur die zwei letzten verglichen werden, um Vorwärtssprünge abzubilden. + checked_energy = None + prev_energy = total_energy + return checked_energy, prev_energy + else: + checked_energy = None + prev_energy = None + return checked_energy, prev_energy diff --git a/packages/modules/common/utils/test_peak_filter.py b/packages/modules/common/utils/test_peak_filter.py index b50d9eedd8..9af278ece9 100644 --- a/packages/modules/common/utils/test_peak_filter.py +++ b/packages/modules/common/utils/test_peak_filter.py @@ -95,3 +95,57 @@ def test_check_values_valid(params): imp, exp = pf.check_values(params.power, params.imported, params.exported) assert imp == params.expected_imported assert exp == params.expected_exported + + +def test_check_total_energy_forward_jump_then_accept_second_high_and_wait_after_backward_jump(): + fs = DummyFaultState() + pf = PeakFilter(ComponentType.COUNTER, 1, fs) + + allowed_deviation = 10 + max_energy = 1_000_000_000 + + # Vorwaertssprung: erster Wert wird verworfen, aber als Vergleichsbasis gespeichert. + checked, previous = pf.check_total_energy(2000, 1000, allowed_deviation, max_energy) + assert checked is None + assert previous == 2000 + + # Zweiter hoher Wert mit kleiner Differenz wird uebernommen. + checked, previous = pf.check_total_energy(2005, previous, allowed_deviation, max_energy) + assert checked == 2005 + assert previous == 2005 + + # Rueckwaertssprung: letzter gueltiger Hoechststand bleibt erhalten. + checked, previous = pf.check_total_energy(1900, previous, allowed_deviation, max_energy) + assert checked == 2005 + assert previous == 2005 + + # Unterhalb des Hoechststands wird weiterhin gewartet. + checked, previous = pf.check_total_energy(1990, previous, allowed_deviation, max_energy) + assert checked == 2005 + assert previous == 2005 + + # Naechster gleicher oder hoeherer Wert wird wieder zugelassen. + checked, previous = pf.check_total_energy(2005, previous, allowed_deviation, max_energy) + assert checked == 2005 + assert previous == 2005 + + checked, previous = pf.check_total_energy(2010, previous, allowed_deviation, max_energy) + assert checked == 2010 + assert previous == 2010 + + # Der niedrigere Folgewert wird einmal verworfen und als neue Vergleichsbasis gesetzt. + allowed_deviation = 10 + max_energy = 3000 + + checked, previous = pf.check_total_energy(4000, 2900, allowed_deviation, max_energy) + assert checked is None + assert previous == 4000 + + checked, previous = pf.check_total_energy(2800, previous, allowed_deviation, max_energy) + assert checked is None + assert previous == 2800 + + # Danach wird ein plausibler Folgewert wieder zugelassen. + checked, previous = pf.check_total_energy(2805, previous, allowed_deviation, max_energy) + assert checked == 2805 + assert previous == 2805 diff --git a/packages/modules/consumers/avm/avm/consumer.py b/packages/modules/consumers/avm/avm/consumer.py index 1b32aaa5bf..0461efc4c4 100644 --- a/packages/modules/consumers/avm/avm/consumer.py +++ b/packages/modules/consumers/avm/avm/consumer.py @@ -21,13 +21,30 @@ def create_consumer(config: Avm): ain: Optional[str] = None # Actuator Identification Number - def update() -> ConsumerState: - nonlocal ain + def get_device_list() -> ET.Element: ensure_valid_session_id() response = req.get_http_session().get( f"http://{config.configuration.ip_address}/webservices/homeautoswitch.lua?sid=" f"{config.configuration.session_id}&switchcmd=getdevicelistinfos") - deviceListElementTree = ET.fromstring(response.text.strip()) + return ET.fromstring(response.text.strip()) + + def ensure_valid_ain(deviceListElementTree: Optional[ET.Element] = None) -> str: + # switch_on/switch_off dürfen nicht davon abhängen, dass update() bereits einmal gelaufen ist - + # sonst würde ein Schaltbefehl vor dem ersten erfolgreichen update() mit ain=None gesendet. + nonlocal ain + if ain is not None: + return ain + for device in deviceListElementTree if deviceListElementTree is not None else get_device_list(): + if device.find("name").text == config.configuration.name and device.find("present").text == '1': + ain = device.attrib["identifier"] + return ain + raise RuntimeError( + f"Device with name '{config.configuration.name}' was not found or is not currently available" + ) + + def update() -> ConsumerState: + deviceListElementTree = get_device_list() + ensure_valid_ain(deviceListElementTree) for device in deviceListElementTree: name = device.find("name").text @@ -36,7 +53,6 @@ def update() -> ConsumerState: if presentText != '1': continue - ain = device.attrib["identifier"] powermeterBlock = device.find("powermeter") if powermeterBlock is not None: # AVM returns mW, convert to W here @@ -67,12 +83,12 @@ def update() -> ConsumerState: ) def ensure_valid_session_id(): - if check_valid_session_id() is False: + if session_id_expired(): config.configuration.session_id = get_session_id() config.configuration.session_mtime = time.time() Pub().pub(f"openWB/set/system/device/{config.id}/config", asdict(config)) - def check_valid_session_id() -> bool: + def session_id_expired() -> bool: return (config.configuration.session_id is None or config.configuration.session_mtime is None or time.time() - config.configuration.session_mtime > 300) @@ -87,7 +103,7 @@ def get_session_id(): challengeResponse = ET.fromstring(response.content) session_id = challengeResponse.find('SID').text if session_id != INVALID_SESSIONID: - return + return session_id blockTimeXML = challengeResponse.find('BlockTime') if blockTimeXML is not None and int(blockTimeXML.text) > 0: raise Exception("Durch Anmeldefehler in der Vergangenheit ist der Zugang zur FRITZ!Box " @@ -119,13 +135,13 @@ def switch_on() -> None: ensure_valid_session_id() req.get_http_session().get( f"http://{config.configuration.ip_address}/webservices/homeautoswitch.lua?sid=" - f"{config.configuration.session_id}&switchcmd=setswitchon&ain={ain}") + f"{config.configuration.session_id}&switchcmd=setswitchon&ain={ensure_valid_ain()}") def switch_off() -> None: ensure_valid_session_id() req.get_http_session().get( f"http://{config.configuration.ip_address}/webservices/homeautoswitch.lua?sid=" - f"{config.configuration.session_id}&switchcmd=setswitchoff&ain={ain}") + f"{config.configuration.session_id}&switchcmd=setswitchoff&ain={ensure_valid_ain()}") return ConfigurableConsumer(consumer_config=config, update=update, diff --git a/packages/modules/consumers/daikin/__init__.py b/packages/modules/consumers/daikin/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/modules/consumers/daikin/daikin_homehub_air2air/__init__.py b/packages/modules/consumers/daikin/daikin_homehub_air2air/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/modules/consumers/daikin/daikin_homehub_air2air/config.py b/packages/modules/consumers/daikin/daikin_homehub_air2air/config.py new file mode 100644 index 0000000000..579b42552d --- /dev/null +++ b/packages/modules/consumers/daikin/daikin_homehub_air2air/config.py @@ -0,0 +1,33 @@ +from typing import Optional, Tuple + +from control.consumer.consumer_data import ConsumerUsage +from helpermodules.auto_str import auto_str +from modules.common.consumer_setup import ConsumerSetup +from ..vendor import vendor_descriptor + + +@auto_str +class DaikinConfiguration: + def __init__(self, + ip_address: Optional[str] = None, + port: int = 502, + modbus_id: int = 1): + self.ip_address = ip_address + self.port = port + self.modbus_id = modbus_id + + +@auto_str +class Daikin(ConsumerSetup[DaikinConfiguration]): + def __init__(self, + name: str = "Daikin Air2Air (HomeHub EKRHH)", + type: str = "daikin_homehub_air2air", + id: int = 0, + configuration: Optional[DaikinConfiguration] = None, + # Register gegen das offizielle EKRHH Installer Reference Guide (4PDE744838, + # Kap. 10 "Modbus TCP/IP oder RTU für Luft-zu-Luft-Wärmepumpe") geprüft. + usage: Tuple[ConsumerUsage, ...] = (ConsumerUsage.SUSPENDABLE_ONOFF, + ConsumerUsage.SUSPENDABLE_TUNABLE), + **kwargs) -> None: + super().__init__(name, type, id, vendor=vendor_descriptor.configuration_factory( + ).type, configuration=configuration or DaikinConfiguration(), usage=usage, **kwargs) diff --git a/packages/modules/consumers/daikin/daikin_homehub_air2air/consumer.py b/packages/modules/consumers/daikin/daikin_homehub_air2air/consumer.py new file mode 100644 index 0000000000..601d4d1cc8 --- /dev/null +++ b/packages/modules/consumers/daikin/daikin_homehub_air2air/consumer.py @@ -0,0 +1,47 @@ +#!/usr/bin/env python3 +import logging +from typing import Optional + +from modules.common.abstract_device import DeviceDescriptor +from modules.common.configurable_consumer import ConfigurableConsumer, SetLimitData +from modules.common.modbus import ModbusDataType, ModbusTcpClient_ +from modules.consumers.daikin.daikin_homehub_air2air.config import Daikin + +log = logging.getLogger(__name__) + + +def create_consumer(config: Daikin): + client: Optional[ModbusTcpClient_] = None + + def initializer(): + nonlocal client + client = ModbusTcpClient_(config.configuration.ip_address, config.configuration.port) + + def switch_on() -> None: + # Reg 1000: Smart-Grid-Modus, 2 = Empfehlung ein (Boost) + client.write_register(1000, 2, data_type=ModbusDataType.INT_16, unit=config.configuration.modbus_id) + + def switch_off() -> None: + # Reg 1000: Smart-Grid-Modus, 0 = freier Betrieb (Normal) + client.write_register(1000, 0, data_type=ModbusDataType.INT_16, unit=config.configuration.modbus_id) + + def set_power_limit(power_limit: Optional[float], data: SetLimitData) -> None: + # Reg 1001: Leistungsgrenze für Bedarfssteuerung, Pow16 (raw = kW * 100), lt. offiziellem + # Daikin-Referenzhandbuch (EKRHH Installer Reference Guide, Kap. 10.2.1/10.2). Bereich 0..20kW. + # Die Einheit setzt intern trotzdem eine Untergrenze von 40% der Nennleistung der + # Außeneinheit um - Werte darunter reduzieren die Leistung nicht weiter; echtes Abschalten + # läuft über switch_off() (Reg 1000, Zwangsabschaltung). + value = 0 if power_limit is None else round(max(power_limit, 0) / 10) + client.write_register(1001, value, data_type=ModbusDataType.INT_16, unit=config.configuration.modbus_id) + + # Keine update()-Funktion: die HomeHub-Anbindung stellt für Air2Air-Geräte keine + # Leistungsmessung zur Verfügung. Wer den Verbrauch erfassen will, braucht eine separate + # Leistungsmessung (zB Zwischenzähler/Smart-Plug), die diesem Verbraucher zugeordnet wird. + return ConfigurableConsumer(consumer_config=config, + initializer=initializer, + switch_on=switch_on, + switch_off=switch_off, + set_power_limit=set_power_limit) + + +device_descriptor = DeviceDescriptor(configuration_factory=Daikin) diff --git a/packages/modules/consumers/daikin/daikin_homehub_altherma/__init__.py b/packages/modules/consumers/daikin/daikin_homehub_altherma/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/modules/consumers/daikin/daikin_homehub_altherma/config.py b/packages/modules/consumers/daikin/daikin_homehub_altherma/config.py new file mode 100644 index 0000000000..1a60ecb5c7 --- /dev/null +++ b/packages/modules/consumers/daikin/daikin_homehub_altherma/config.py @@ -0,0 +1,34 @@ +from typing import Optional, Tuple + +from control.consumer.consumer_data import ConsumerUsage +from helpermodules.auto_str import auto_str +from modules.common.consumer_setup import ConsumerSetup +from ..vendor import vendor_descriptor + + +@auto_str +class DaikinAlthermaConfiguration: + def __init__(self, + ip_address: Optional[str] = None, + port: int = 502, + modbus_id: int = 1): + self.ip_address = ip_address + self.port = port + self.modbus_id = modbus_id + + +@auto_str +class DaikinAltherma(ConsumerSetup[DaikinAlthermaConfiguration]): + def __init__(self, + name: str = "Daikin Altherma (HomeHub)", + type: str = "daikin_homehub_altherma", + id: int = 0, + configuration: Optional[DaikinAlthermaConfiguration] = None, + # Register gegen das offizielle EKRHH Installer Reference Guide (4PDE744838, + # Kap. 9 "Modbus TCP/IP oder RTU für Daikin Altherma") geprüft. + usage: Tuple[ConsumerUsage, ...] = (ConsumerUsage.SUSPENDABLE_ONOFF, + ConsumerUsage.SUSPENDABLE_TUNABLE, + ConsumerUsage.METER_ONLY), + **kwargs) -> None: + super().__init__(name, type, id, vendor=vendor_descriptor.configuration_factory( + ).type, configuration=configuration or DaikinAlthermaConfiguration(), usage=usage, **kwargs) diff --git a/packages/modules/consumers/daikin/daikin_homehub_altherma/consumer.py b/packages/modules/consumers/daikin/daikin_homehub_altherma/consumer.py new file mode 100644 index 0000000000..31ac5ebe9b --- /dev/null +++ b/packages/modules/consumers/daikin/daikin_homehub_altherma/consumer.py @@ -0,0 +1,59 @@ +#!/usr/bin/env python3 +import logging +from typing import Optional + +from modules.common.abstract_device import DeviceDescriptor +from modules.common.component_state import ConsumerState +from modules.common.component_type import ComponentType +from modules.common.configurable_consumer import ConfigurableConsumer, SetLimitData +from modules.common.modbus import ModbusDataType, ModbusTcpClient_ +from modules.common.simcount._simcounter import SimCounterConsumer +from modules.consumers.daikin.daikin_homehub_altherma.config import DaikinAltherma + +log = logging.getLogger(__name__) + + +def create_consumer(config: DaikinAltherma): + client: Optional[ModbusTcpClient_] = None + sim_counter: Optional[SimCounterConsumer] = None + + def initializer(): + nonlocal client, sim_counter + client = ModbusTcpClient_(config.configuration.ip_address, config.configuration.port) + sim_counter = SimCounterConsumer(config.id, ComponentType.CONSUMER) + + def update() -> ConsumerState: + # Reg 51: Stromverbrauch Wärmepumpe, Pow16 (raw = kW * 100) + power = client.read_input_registers( + 50, ModbusDataType.INT_16, unit=config.configuration.modbus_id) * 10 + imported, exported = sim_counter.sim_count(power) + return ConsumerState(power=power, imported=imported, exported=exported) + + def switch_on() -> None: + # Reg 56: Smart-Grid-Modus, 2 = Empfehlung ein (Boost) + client.write_register(55, 2, data_type=ModbusDataType.INT_16, unit=config.configuration.modbus_id) + + def switch_off() -> None: + # Reg 56: Smart-Grid-Modus, 0 = freier Betrieb (Normal) + client.write_register(55, 0, data_type=ModbusDataType.INT_16, unit=config.configuration.modbus_id) + + def set_power_limit(power_limit: Optional[float], data: SetLimitData) -> None: + # Reg 58: Allgemeine Leistungsbegrenzung, Pow16 (raw = kW * 100), lt. offiziellem + # Daikin-Referenzhandbuch (EKRHH Installer Reference Guide, Kap. 9.2.1/9.3). Bereich 0..20kW, + # gilt laut Referenzhandbuch unabhängig vom Smart-Grid-Modus (anders als Reg 57, das nur + # während der Pufferung im Modus "Empfehlung ein" greift). + # Die Einheit setzt intern trotzdem eine Untergrenze von 4,5kW für 15 Minuten nach + # Verdichterstart durch - Werte darunter reduzieren die Leistung in dieser Zeit nicht weiter; + # echtes Abschalten läuft über switch_off() (Reg 56, Zwangsabschaltung). + value = 0 if power_limit is None else round(max(power_limit, 0) / 10) + client.write_register(57, value, data_type=ModbusDataType.INT_16, unit=config.configuration.modbus_id) + + return ConfigurableConsumer(consumer_config=config, + initializer=initializer, + update=update, + switch_on=switch_on, + switch_off=switch_off, + set_power_limit=set_power_limit) + + +device_descriptor = DeviceDescriptor(configuration_factory=DaikinAltherma) diff --git a/packages/modules/consumers/daikin/vendor.py b/packages/modules/consumers/daikin/vendor.py new file mode 100644 index 0000000000..1e0201b718 --- /dev/null +++ b/packages/modules/consumers/daikin/vendor.py @@ -0,0 +1,14 @@ +from pathlib import Path + +from modules.common.abstract_device import DeviceDescriptor +from modules.devices.vendors import VendorGroup + + +class Vendor: + def __init__(self): + self.type = Path(__file__).parent.name + self.vendor = "Daikin" + self.group = VendorGroup.VENDORS.value + + +vendor_descriptor = DeviceDescriptor(configuration_factory=Vendor) diff --git a/packages/modules/consumers/ego/__init__.py b/packages/modules/consumers/ego/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/modules/consumers/ego/ego/__init__.py b/packages/modules/consumers/ego/ego/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/modules/consumers/ego/ego/config.py b/packages/modules/consumers/ego/ego/config.py new file mode 100644 index 0000000000..c0f2c6ea73 --- /dev/null +++ b/packages/modules/consumers/ego/ego/config.py @@ -0,0 +1,32 @@ +from typing import Optional, Tuple + +from control.consumer.consumer_data import ConsumerUsage +from helpermodules.auto_str import auto_str +from modules.common.consumer_setup import ConsumerSetup +from ..vendor import vendor_descriptor + + +@auto_str +class EgoConfiguration: + def __init__(self, + ip_address: Optional[str] = None, + port: int = 502, + modbus_id: int = 247): + self.ip_address = ip_address + self.port = port + self.modbus_id = modbus_id + + +@auto_str +class Ego(ConsumerSetup[EgoConfiguration]): + def __init__(self, + name: str = "E.G.O. Smart Heater", + type: str = "ego", + id: int = 0, + configuration: Optional[EgoConfiguration] = None, + usage: Tuple[ConsumerUsage, ...] = (ConsumerUsage.SUSPENDABLE_TUNABLE, + ConsumerUsage.SELF_CONTROLLED, + ConsumerUsage.METER_ONLY), + **kwargs) -> None: + super().__init__(name, type, id, vendor=vendor_descriptor.configuration_factory( + ).type, configuration=configuration or EgoConfiguration(), usage=usage, **kwargs) diff --git a/packages/modules/consumers/ego/ego/consumer.py b/packages/modules/consumers/ego/ego/consumer.py new file mode 100644 index 0000000000..cf84e0a683 --- /dev/null +++ b/packages/modules/consumers/ego/ego/consumer.py @@ -0,0 +1,63 @@ +#!/usr/bin/env python3 +import logging +from typing import Optional + +from modules.common.abstract_consumer import CurrentValues +from modules.common.abstract_device import DeviceDescriptor +from modules.common.component_state import ConsumerState +from modules.common.component_type import ComponentType +from modules.common.configurable_consumer import ConfigurableConsumer, SetLimitData +from modules.common.modbus import ModbusDataType, ModbusTcpClient_ +from modules.common.simcount._simcounter import SimCounterConsumer +from modules.consumers.ego.ego.config import Ego + +log = logging.getLogger(__name__) + + +def create_consumer(config: Ego): + client: Optional[ModbusTcpClient_] = None + sim_counter: Optional[SimCounterConsumer] = None + + def initializer(): + nonlocal client, sim_counter + client = ModbusTcpClient_(config.configuration.ip_address, config.configuration.port) + sim_counter = SimCounterConsumer(config.id, ComponentType.CONSUMER) + + def send_values(values: CurrentValues) -> None: + unit = config.configuration.modbus_id + # Reg 4864 = -1: Heizstab nutzt selbst Reg 4865 (HomeTotalPower) zur Regelung. + client.write_register(4864, -1, data_type=ModbusDataType.INT_16, unit=unit) + # Reg 4865/4866: Netzsaldo, negativ = Überschuss (gleiche Konvention wie evu_power). + client.write_register(4865, round(values.evu_power), data_type=ModbusDataType.INT_32, unit=unit) + + def update() -> ConsumerState: + unit = config.configuration.modbus_id + status = client.read_holding_registers(5128, ModbusDataType.UINT_16, unit=unit) + power = 0 + if status & 0x01: + power += client.read_holding_registers(4096, ModbusDataType.UINT_16, unit=unit) + if status & 0x02: + power += client.read_holding_registers(4128, ModbusDataType.UINT_16, unit=unit) + if status & 0x04: + power += client.read_holding_registers(4160, ModbusDataType.UINT_16, unit=unit) + imported, exported = sim_counter.sim_count(power) + + return ConsumerState( + power=power, + imported=imported, + exported=exported + ) + + def set_power_limit(power_limit: Optional[float], data: SetLimitData) -> None: + # Reg 4864: gewünschte Leistung [W], bestmöglich auf die 500/1000/2000W-Relais verteilt. + value = 0 if power_limit is None else max(round(power_limit), 0) + client.write_register(4864, value, data_type=ModbusDataType.INT_16, unit=config.configuration.modbus_id) + + return ConfigurableConsumer(consumer_config=config, + initializer=initializer, + update=update, + send_values=send_values, + set_power_limit=set_power_limit) + + +device_descriptor = DeviceDescriptor(configuration_factory=Ego) diff --git a/packages/modules/consumers/ego/vendor.py b/packages/modules/consumers/ego/vendor.py new file mode 100644 index 0000000000..c6114229e0 --- /dev/null +++ b/packages/modules/consumers/ego/vendor.py @@ -0,0 +1,14 @@ +from pathlib import Path + +from modules.common.abstract_device import DeviceDescriptor +from modules.devices.vendors import VendorGroup + + +class Vendor: + def __init__(self): + self.type = Path(__file__).parent.name + self.vendor = "E.G.O." + self.group = VendorGroup.VENDORS.value + + +vendor_descriptor = DeviceDescriptor(configuration_factory=Vendor) diff --git a/packages/modules/consumers/ems_esp/__init__.py b/packages/modules/consumers/ems_esp/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/modules/consumers/ems_esp/ems_esp/__init__.py b/packages/modules/consumers/ems_esp/ems_esp/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/modules/consumers/ems_esp/ems_esp/config.py b/packages/modules/consumers/ems_esp/ems_esp/config.py new file mode 100644 index 0000000000..ba89be03e8 --- /dev/null +++ b/packages/modules/consumers/ems_esp/ems_esp/config.py @@ -0,0 +1,33 @@ +from typing import Optional, Tuple + +from control.consumer.consumer_data import ConsumerUsage +from helpermodules.auto_str import auto_str +from modules.common.consumer_setup import ConsumerSetup +from ..vendor import vendor_descriptor + + +@auto_str +class EmsEspConfiguration: + def __init__(self, + ip_address: Optional[str] = None, + token: Optional[str] = None): + self.ip_address = ip_address + self.token = token + + +@auto_str +class EmsEsp(ConsumerSetup[EmsEspConfiguration]): + def __init__(self, + name: str = "EMS-ESP Wärmepumpe (Bosch/Buderus/Junkers)", + type: str = "ems_esp", + id: int = 0, + configuration: Optional[EmsEspConfiguration] = None, + # kein SELF_CONTROLLED: die energyPriceEl/-PV-Entities steuern laut + # EMS-ESP32-Quellcode die Wärmequellen-Auswahl bei Hybridanlagen, nicht die + # Kompressorleistung einer reinen Wärmepumpe. + usage: Tuple[ConsumerUsage, ...] = (ConsumerUsage.SUSPENDABLE_ONOFF, + ConsumerUsage.SUSPENDABLE_TUNABLE, + ConsumerUsage.METER_ONLY), + **kwargs) -> None: + super().__init__(name, type, id, vendor=vendor_descriptor.configuration_factory( + ).type, configuration=configuration or EmsEspConfiguration(), usage=usage, **kwargs) diff --git a/packages/modules/consumers/ems_esp/ems_esp/consumer.py b/packages/modules/consumers/ems_esp/ems_esp/consumer.py new file mode 100644 index 0000000000..802cbff11c --- /dev/null +++ b/packages/modules/consumers/ems_esp/ems_esp/consumer.py @@ -0,0 +1,79 @@ +#!/usr/bin/env python3 +import logging +from typing import Optional + +from modules.common import req +from modules.common.abstract_device import DeviceDescriptor +from modules.common.component_state import ConsumerState +from modules.common.component_type import ComponentType +from modules.common.configurable_consumer import ConfigurableConsumer, SetLimitData +from modules.common.simcount._simcounter import SimCounterConsumer +from modules.consumers.ems_esp.ems_esp.config import EmsEsp + +log = logging.getLogger(__name__) + + +def create_consumer(config: EmsEsp): + session: Optional[req.CustomSession] = None + sim_counter: Optional[SimCounterConsumer] = None + base_url: str = "" + headers: dict = {} + + def initializer(): + nonlocal session, sim_counter, base_url, headers + session = req.get_http_session() + sim_counter = SimCounterConsumer(config.id, ComponentType.CONSUMER) + # "heatpump" ist der EMS-ESP-Gerätename für eine reine Wärmepumpe; Hybridanlagen mit + # Gas-/Ölkessel laufen zB unter "boiler" (https://emsesp.org/All-Entities). + base_url = f"http://{config.configuration.ip_address}/api/heatpump" + headers = {"Content-Type": "application/json", + "Authorization": f"Bearer {config.configuration.token}"} + + def _write_entity(entity: str, value) -> None: + session.post(f"{base_url}/{entity}", json={"value": value}, headers=headers) + + def switch_on() -> None: + _write_entity("hpin4opt", "1xxxxxxxxxxx") + + def switch_off() -> None: + _write_entity("hpin4opt", "0xxxxxxxxxxx") + _write_entity("hpin1opt", "0xxxxxxxxxxxxxx") + + def update() -> ConsumerState: + # fehlender Schlüssel (zB falsches Modell) kommt als 200 OK zurück, nicht als HTTP-Fehler - + # ohne diese Prüfung würde das unbemerkt als 0W durchgehen. + values = session.get(base_url, headers=headers).json() + if "hpcurrpower" not in values: + raise ValueError( + f"Entity 'hpcurrpower' nicht in der Antwort von {base_url} enthalten - " + f"vorhandene Werte: {list(values.keys())}") + power = float(values["hpcurrpower"]) + imported, exported = sim_counter.sim_count(power) + + return ConsumerState( + power=power, + imported=imported, + exported=exported + ) + + def set_power_limit(power_limit: Optional[float], data: SetLimitData) -> None: + # pvmaxcomp wirkt nur bei aktivem SG-Ready-Boost (Eingang 4); bei SUSPENDABLE_TUNABLE ruft + # der Core nie switch_on, daher hier selbst mitschalten. Wirksamkeit von pvmaxcomp ist + # modellabhängig (EMS-ESP32 Diskussion #2062: funktioniert auf Buderus WLW186i-12E, nicht + # auf Bosch Compress CS7000iAW). + if power_limit is None: + _write_entity("hpin4opt", "0xxxxxxxxxxx") + _write_entity("hpin1opt", "0xxxxxxxxxxxxxx") + else: + _write_entity("hpin4opt", "1xxxxxxxxxxx") + _write_entity("pvmaxcomp", round(max(power_limit, 0) / 1000, 1)) + + return ConfigurableConsumer(consumer_config=config, + initializer=initializer, + update=update, + set_power_limit=set_power_limit, + switch_on=switch_on, + switch_off=switch_off) + + +device_descriptor = DeviceDescriptor(configuration_factory=EmsEsp) diff --git a/packages/modules/consumers/ems_esp/vendor.py b/packages/modules/consumers/ems_esp/vendor.py new file mode 100644 index 0000000000..aae2c507b2 --- /dev/null +++ b/packages/modules/consumers/ems_esp/vendor.py @@ -0,0 +1,14 @@ +from pathlib import Path + +from modules.common.abstract_device import DeviceDescriptor +from modules.devices.vendors import VendorGroup + + +class Vendor: + def __init__(self): + self.type = Path(__file__).parent.name + self.vendor = "EMS-ESP" + self.group = VendorGroup.VENDORS.value + + +vendor_descriptor = DeviceDescriptor(configuration_factory=Vendor) diff --git a/packages/modules/consumers/gree/__init__.py b/packages/modules/consumers/gree/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/modules/consumers/gree/gree/__init__.py b/packages/modules/consumers/gree/gree/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/modules/consumers/gree/gree/config.py b/packages/modules/consumers/gree/gree/config.py new file mode 100644 index 0000000000..90838af3e3 --- /dev/null +++ b/packages/modules/consumers/gree/gree/config.py @@ -0,0 +1,30 @@ +from typing import Optional, Tuple + +from control.consumer.consumer_data import ConsumerUsage +from helpermodules.auto_str import auto_str +from modules.common.consumer_setup import ConsumerSetup +from ..vendor import vendor_descriptor + + +@auto_str +class GreeConfiguration: + def __init__(self, + ip_address: Optional[str] = None, + port: int = 7000): + self.ip_address = ip_address + self.port = port + + +@auto_str +class Gree(ConsumerSetup[GreeConfiguration]): + def __init__(self, + name: str = "Gree Klimaanlage", + type: str = "gree", + id: int = 0, + configuration: Optional[GreeConfiguration] = None, + # UNVERIFIZIERT: kein offizielles Gree-Protokoll veröffentlicht, community-seitig + # reverse-engineert (mehrere unabhängige Implementierungen stimmen überein). + usage: Tuple[ConsumerUsage, ...] = (ConsumerUsage.SUSPENDABLE_ONOFF,), + **kwargs) -> None: + super().__init__(name, type, id, vendor=vendor_descriptor.configuration_factory( + ).type, configuration=configuration or GreeConfiguration(), usage=usage, **kwargs) diff --git a/packages/modules/consumers/gree/gree/consumer.py b/packages/modules/consumers/gree/gree/consumer.py new file mode 100644 index 0000000000..ef5b96763f --- /dev/null +++ b/packages/modules/consumers/gree/gree/consumer.py @@ -0,0 +1,86 @@ +#!/usr/bin/env python3 +import base64 +import json +import logging +import socket +from typing import Optional + +from cryptography.hazmat.primitives import padding +from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes + +from modules.common.abstract_device import DeviceDescriptor +from modules.common.configurable_consumer import ConfigurableConsumer +from modules.consumers.gree.gree.config import Gree + +log = logging.getLogger(__name__) + +# Generischer Schlüssel für Scan/Bind, identisch bei allen Gree-Geräten. +GENERIC_KEY = b"a3K8Bx%2r8Y7#xDh" + + +def _encrypt(key: bytes, payload: dict) -> str: + padder = padding.PKCS7(128).padder() + data = padder.update(json.dumps(payload).encode()) + padder.finalize() + encryptor = Cipher(algorithms.AES(key), modes.ECB()).encryptor() + return base64.b64encode(encryptor.update(data) + encryptor.finalize()).decode() + + +def _decrypt(key: bytes, pack: str) -> dict: + decryptor = Cipher(algorithms.AES(key), modes.ECB()).decryptor() + padded = decryptor.update(base64.b64decode(pack)) + decryptor.finalize() + unpadder = padding.PKCS7(128).unpadder() + data = unpadder.update(padded) + unpadder.finalize() + return json.loads(data) + + +def _request(ip_address: str, port: int, envelope: dict, timeout: float = 3.0) -> dict: + with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock: + sock.settimeout(timeout) + sock.sendto(json.dumps(envelope).encode(), (ip_address, port)) + data, _ = sock.recvfrom(4096) + return json.loads(data) + + +def create_consumer(config: Gree): + mac: Optional[str] = None + device_key: Optional[bytes] = None + + def initializer(): + nonlocal mac, device_key + ip_address = config.configuration.ip_address + port = config.configuration.port + scan_resp = _request(ip_address, port, {"t": "scan"}) + device_info = _decrypt(GENERIC_KEY, scan_resp["pack"]) + mac = device_info["mac"] + + bind_pack = _encrypt(GENERIC_KEY, {"mac": mac, "t": "bind", "uid": 0}) + bind_envelope = {"cid": "app", "tcid": mac, "i": 1, "t": "pack", "uid": 0, "pack": bind_pack} + bind_resp = _request(ip_address, port, bind_envelope) + bind_result = _decrypt(GENERIC_KEY, bind_resp["pack"]) + device_key = bind_result["key"].encode() + + def error_handler() -> None: + initializer() + + def _send_command(opt: list, p: list) -> None: + pack = _encrypt(device_key, {"opt": opt, "p": p, "t": "cmd"}) + envelope = {"cid": "app", "i": 0, "t": "pack", "uid": 0, "tcid": mac, "pack": pack} + _request(config.configuration.ip_address, config.configuration.port, envelope) + + def switch_on() -> None: + _send_command(["Pow"], [1]) + + def switch_off() -> None: + _send_command(["Pow"], [0]) + + # Keine update()-Funktion: der Statusabruf liefert Betriebsparameter (Modus, Solltemperatur + # etc.), aber keine Leistungsaufnahme. Wer den Verbrauch erfassen will, braucht eine separate + # Leistungsmessung (zB Zwischenzähler/Smart-Plug), die diesem Verbraucher zugeordnet wird. + return ConfigurableConsumer(consumer_config=config, + initializer=initializer, + error_handler=error_handler, + switch_on=switch_on, + switch_off=switch_off) + + +device_descriptor = DeviceDescriptor(configuration_factory=Gree) diff --git a/packages/modules/consumers/gree/vendor.py b/packages/modules/consumers/gree/vendor.py new file mode 100644 index 0000000000..43ac8cb9fd --- /dev/null +++ b/packages/modules/consumers/gree/vendor.py @@ -0,0 +1,14 @@ +from pathlib import Path + +from modules.common.abstract_device import DeviceDescriptor +from modules.devices.vendors import VendorGroup + + +class Vendor: + def __init__(self): + self.type = Path(__file__).parent.name + self.vendor = "Gree" + self.group = VendorGroup.VENDORS.value + + +vendor_descriptor = DeviceDescriptor(configuration_factory=Vendor) diff --git a/packages/modules/consumers/kermi/__init__.py b/packages/modules/consumers/kermi/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/modules/consumers/kermi/kermi/__init__.py b/packages/modules/consumers/kermi/kermi/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/modules/consumers/kermi/kermi/config.py b/packages/modules/consumers/kermi/kermi/config.py new file mode 100644 index 0000000000..a5a778a649 --- /dev/null +++ b/packages/modules/consumers/kermi/kermi/config.py @@ -0,0 +1,31 @@ +from typing import Optional, Tuple + +from control.consumer.consumer_data import ConsumerUsage +from helpermodules.auto_str import auto_str +from modules.common.consumer_setup import ConsumerSetup +from ..vendor import vendor_descriptor + + +@auto_str +class KermiConfiguration: + def __init__(self, + ip_address: Optional[str] = None, + port: int = 502, + modbus_id: int = 40): + self.ip_address = ip_address + self.port = port + self.modbus_id = modbus_id + + +@auto_str +class Kermi(ConsumerSetup[KermiConfiguration]): + def __init__(self, + name: str = "Kermi Wärmepumpe (x-center/x-change)", + type: str = "kermi", + id: int = 0, + configuration: Optional[KermiConfiguration] = None, + usage: Tuple[ConsumerUsage, ...] = (ConsumerUsage.SUSPENDABLE_TUNABLE, + ConsumerUsage.METER_ONLY), + **kwargs) -> None: + super().__init__(name, type, id, vendor=vendor_descriptor.configuration_factory( + ).type, configuration=configuration or KermiConfiguration(), usage=usage, **kwargs) diff --git a/packages/modules/consumers/kermi/kermi/consumer.py b/packages/modules/consumers/kermi/kermi/consumer.py new file mode 100644 index 0000000000..82c9283ce5 --- /dev/null +++ b/packages/modules/consumers/kermi/kermi/consumer.py @@ -0,0 +1,48 @@ +#!/usr/bin/env python3 +import logging +from typing import Optional + +from modules.common.abstract_device import DeviceDescriptor +from modules.common.component_state import ConsumerState +from modules.common.component_type import ComponentType +from modules.common.configurable_consumer import ConfigurableConsumer, SetLimitData +from modules.common.modbus import ModbusDataType, ModbusTcpClient_ +from modules.common.simcount._simcounter import SimCounterConsumer +from modules.consumers.kermi.kermi.config import Kermi + +log = logging.getLogger(__name__) + + +def create_consumer(config: Kermi): + client: Optional[ModbusTcpClient_] = None + sim_counter: Optional[SimCounterConsumer] = None + + def initializer(): + nonlocal client, sim_counter + client = ModbusTcpClient_(config.configuration.ip_address, config.configuration.port) + sim_counter = SimCounterConsumer(config.id, ComponentType.CONSUMER) + + def update() -> ConsumerState: + # Reg 108: aktuelle Leistungsaufnahme, Auflösung 100W + power = client.read_holding_registers( + 108, ModbusDataType.INT_16, unit=config.configuration.modbus_id) * 100 + imported, exported = sim_counter.sim_count(power) + + return ConsumerState( + power=power, + imported=imported, + exported=exported + ) + + def set_power_limit(power_limit: Optional[float], data: SetLimitData) -> None: + # Reg 301: PV-Überschussleistung, Auflösung 0,1W (raw = W * 10) + value = int(round((0 if power_limit is None else max(power_limit, 0)) * 10)) + client.write_register(301, value, data_type=ModbusDataType.INT_16, unit=config.configuration.modbus_id) + + return ConfigurableConsumer(consumer_config=config, + initializer=initializer, + update=update, + set_power_limit=set_power_limit) + + +device_descriptor = DeviceDescriptor(configuration_factory=Kermi) diff --git a/packages/modules/consumers/kermi/vendor.py b/packages/modules/consumers/kermi/vendor.py new file mode 100644 index 0000000000..1351918096 --- /dev/null +++ b/packages/modules/consumers/kermi/vendor.py @@ -0,0 +1,14 @@ +from pathlib import Path + +from modules.common.abstract_device import DeviceDescriptor +from modules.devices.vendors import VendorGroup + + +class Vendor: + def __init__(self): + self.type = Path(__file__).parent.name + self.vendor = "Kermi" + self.group = VendorGroup.VENDORS.value + + +vendor_descriptor = DeviceDescriptor(configuration_factory=Vendor) diff --git a/packages/modules/consumers/lg_therma/__init__.py b/packages/modules/consumers/lg_therma/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/modules/consumers/lg_therma/lg_therma/__init__.py b/packages/modules/consumers/lg_therma/lg_therma/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/modules/consumers/lg_therma/lg_therma/config.py b/packages/modules/consumers/lg_therma/lg_therma/config.py new file mode 100644 index 0000000000..ad8fbc259c --- /dev/null +++ b/packages/modules/consumers/lg_therma/lg_therma/config.py @@ -0,0 +1,33 @@ +from typing import Optional, Tuple + +from control.consumer.consumer_data import ConsumerUsage +from helpermodules.auto_str import auto_str +from modules.common.consumer_setup import ConsumerSetup +from ..vendor import vendor_descriptor + + +@auto_str +class LgThermaConfiguration: + def __init__(self, + ip_address: Optional[str] = None, + port: int = 502, + modbus_id: int = 33): + self.ip_address = ip_address + self.port = port + self.modbus_id = modbus_id + + +@auto_str +class LgTherma(ConsumerSetup[LgThermaConfiguration]): + def __init__(self, + name: str = "LG Therma V Wärmepumpe", + type: str = "lg_therma", + id: int = 0, + configuration: Optional[LgThermaConfiguration] = None, + # UNVERIFIZIERT: keine offizielle LG-Dokumentation für dieses Register gefunden. + # PI-485-Protokoll selbst ist offiziell dokumentiert. + usage: Tuple[ConsumerUsage, ...] = (ConsumerUsage.SUSPENDABLE_ONOFF, + ConsumerUsage.METER_ONLY), + **kwargs) -> None: + super().__init__(name, type, id, vendor=vendor_descriptor.configuration_factory( + ).type, configuration=configuration or LgThermaConfiguration(), usage=usage, **kwargs) diff --git a/packages/modules/consumers/lg_therma/lg_therma/consumer.py b/packages/modules/consumers/lg_therma/lg_therma/consumer.py new file mode 100644 index 0000000000..2eaccafe4d --- /dev/null +++ b/packages/modules/consumers/lg_therma/lg_therma/consumer.py @@ -0,0 +1,51 @@ +#!/usr/bin/env python3 +import logging +from typing import Optional + +from modules.common.abstract_device import DeviceDescriptor +from modules.common.component_state import ConsumerState +from modules.common.component_type import ComponentType +from modules.common.configurable_consumer import ConfigurableConsumer +from modules.common.modbus import ModbusDataType, ModbusTcpClient_ +from modules.common.simcount._simcounter import SimCounterConsumer +from modules.consumers.lg_therma.lg_therma.config import LgTherma + +log = logging.getLogger(__name__) + + +def create_consumer(config: LgTherma): + client: Optional[ModbusTcpClient_] = None + sim_counter: Optional[SimCounterConsumer] = None + + def initializer(): + nonlocal client, sim_counter + client = ModbusTcpClient_(config.configuration.ip_address, config.configuration.port) + sim_counter = SimCounterConsumer(config.id, ComponentType.CONSUMER) + + def switch_on() -> None: + # Reg 9: Energy State, 3 = Ein-Empfehlung (Boost) + client.write_register(9, 3, data_type=ModbusDataType.UINT_16, unit=config.configuration.modbus_id) + + def switch_off() -> None: + # Reg 9: Energy State, 2 = Normalbetrieb + client.write_register(9, 2, data_type=ModbusDataType.UINT_16, unit=config.configuration.modbus_id) + + def update() -> ConsumerState: + # Reg 35: elektrische Leistungsaufnahme [W] + power = client.read_input_registers(35, ModbusDataType.UINT_16, unit=config.configuration.modbus_id) + imported, exported = sim_counter.sim_count(power) + + return ConsumerState( + power=power, + imported=imported, + exported=exported + ) + + return ConfigurableConsumer(consumer_config=config, + initializer=initializer, + update=update, + switch_on=switch_on, + switch_off=switch_off) + + +device_descriptor = DeviceDescriptor(configuration_factory=LgTherma) diff --git a/packages/modules/consumers/lg_therma/vendor.py b/packages/modules/consumers/lg_therma/vendor.py new file mode 100644 index 0000000000..d69bb85b5f --- /dev/null +++ b/packages/modules/consumers/lg_therma/vendor.py @@ -0,0 +1,14 @@ +from pathlib import Path + +from modules.common.abstract_device import DeviceDescriptor +from modules.devices.vendors import VendorGroup + + +class Vendor: + def __init__(self): + self.type = Path(__file__).parent.name + self.vendor = "LG" + self.group = VendorGroup.VENDORS.value + + +vendor_descriptor = DeviceDescriptor(configuration_factory=Vendor) diff --git a/packages/modules/consumers/luxtronik/__init__.py b/packages/modules/consumers/luxtronik/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/modules/consumers/luxtronik/luxtronik/__init__.py b/packages/modules/consumers/luxtronik/luxtronik/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/modules/consumers/luxtronik/luxtronik/config.py b/packages/modules/consumers/luxtronik/luxtronik/config.py new file mode 100644 index 0000000000..207ea3d064 --- /dev/null +++ b/packages/modules/consumers/luxtronik/luxtronik/config.py @@ -0,0 +1,36 @@ +from typing import Optional, Tuple + +from control.consumer.consumer_data import ConsumerUsage +from helpermodules.auto_str import auto_str +from modules.common.consumer_setup import ConsumerSetup +from ..vendor import vendor_descriptor + + +@auto_str +class LuxtronikConfiguration: + def __init__(self, + ip_address: Optional[str] = None, + port: int = 502, + modbus_id: int = 1, + # Anhebung von Heiz- und Warmwassersolltemperatur im Überschussbetrieb [K] - + # abhängig vom Heizsystem (Fußbodenheizung/Heizkörper, Puffergröße), daher + # bewusst konfigurierbar statt fest verdrahtet. + boost_offset: float = 5.0): + self.ip_address = ip_address + self.port = port + self.modbus_id = modbus_id + self.boost_offset = boost_offset + + +@auto_str +class Luxtronik(ConsumerSetup[LuxtronikConfiguration]): + def __init__(self, + name: str = "Luxtronik 2.1 Wärmepumpe (Alpha Innotec/Buderus Logamatic HMC/Novelan/Roth/Wolf u.a.)", + type: str = "luxtronik", + id: int = 0, + configuration: Optional[LuxtronikConfiguration] = None, + usage: Tuple[ConsumerUsage, ...] = (ConsumerUsage.SUSPENDABLE_ONOFF, + ConsumerUsage.METER_ONLY), + **kwargs) -> None: + super().__init__(name, type, id, vendor=vendor_descriptor.configuration_factory( + ).type, configuration=configuration or LuxtronikConfiguration(), usage=usage, **kwargs) diff --git a/packages/modules/consumers/luxtronik/luxtronik/consumer.py b/packages/modules/consumers/luxtronik/luxtronik/consumer.py new file mode 100644 index 0000000000..2b662938c3 --- /dev/null +++ b/packages/modules/consumers/luxtronik/luxtronik/consumer.py @@ -0,0 +1,61 @@ +#!/usr/bin/env python3 +import logging +from typing import Optional + +from modules.common.abstract_device import DeviceDescriptor +from modules.common.component_state import ConsumerState +from modules.common.component_type import ComponentType +from modules.common.configurable_consumer import ConfigurableConsumer +from modules.common.modbus import ModbusDataType, ModbusTcpClient_ +from modules.common.simcount._simcounter import SimCounterConsumer +from modules.consumers.luxtronik.luxtronik.config import Luxtronik + +log = logging.getLogger(__name__) + + +def create_consumer(config: Luxtronik): + client: Optional[ModbusTcpClient_] = None + sim_counter: Optional[SimCounterConsumer] = None + + def initializer(): + nonlocal client, sim_counter + client = ModbusTcpClient_(config.configuration.ip_address, config.configuration.port) + sim_counter = SimCounterConsumer(config.id, ComponentType.CONSUMER) + + def switch_on() -> None: + unit = config.configuration.modbus_id + offset = round(config.configuration.boost_offset * 10) + # Reg 10040: LPC-Modus 0=No-Limit. Reg 10000/10005: Heiz./WW-Modus 2=Offset, + # Reg 10002/10007: Offset [0.1K]. + client.write_register(10040, 0, data_type=ModbusDataType.UINT_16, unit=unit) + client.write_register(10000, 2, data_type=ModbusDataType.UINT_16, unit=unit) + client.write_register(10002, offset, data_type=ModbusDataType.INT_16, unit=unit) + client.write_register(10005, 2, data_type=ModbusDataType.UINT_16, unit=unit) + client.write_register(10007, offset, data_type=ModbusDataType.INT_16, unit=unit) + + def switch_off() -> None: + unit = config.configuration.modbus_id + client.write_register(10040, 0, data_type=ModbusDataType.UINT_16, unit=unit) + client.write_register(10000, 0, data_type=ModbusDataType.UINT_16, unit=unit) + client.write_register(10005, 0, data_type=ModbusDataType.UINT_16, unit=unit) + + def update() -> ConsumerState: + # Reg 10301: el. Leistungsaufnahme, Auflösung 100W (kW x0.1) + power = client.read_input_registers( + 10301, ModbusDataType.UINT_16, unit=config.configuration.modbus_id) * 100 + imported, exported = sim_counter.sim_count(power) + + return ConsumerState( + power=power, + imported=imported, + exported=exported + ) + + return ConfigurableConsumer(consumer_config=config, + initializer=initializer, + update=update, + switch_on=switch_on, + switch_off=switch_off) + + +device_descriptor = DeviceDescriptor(configuration_factory=Luxtronik) diff --git a/packages/modules/consumers/luxtronik/vendor.py b/packages/modules/consumers/luxtronik/vendor.py new file mode 100644 index 0000000000..37196cb318 --- /dev/null +++ b/packages/modules/consumers/luxtronik/vendor.py @@ -0,0 +1,14 @@ +from pathlib import Path + +from modules.common.abstract_device import DeviceDescriptor +from modules.devices.vendors import VendorGroup + + +class Vendor: + def __init__(self): + self.type = Path(__file__).parent.name + self.vendor = "Luxtronik" + self.group = VendorGroup.VENDORS.value + + +vendor_descriptor = DeviceDescriptor(configuration_factory=Vendor) diff --git a/packages/modules/consumers/mitsubishi/__init__.py b/packages/modules/consumers/mitsubishi/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/modules/consumers/mitsubishi/mitsubishi_modbus/__init__.py b/packages/modules/consumers/mitsubishi/mitsubishi_modbus/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/modules/consumers/mitsubishi/mitsubishi_modbus/config.py b/packages/modules/consumers/mitsubishi/mitsubishi_modbus/config.py new file mode 100644 index 0000000000..0c6c5f3a81 --- /dev/null +++ b/packages/modules/consumers/mitsubishi/mitsubishi_modbus/config.py @@ -0,0 +1,30 @@ +from typing import Optional, Tuple + +from control.consumer.consumer_data import ConsumerUsage +from helpermodules.auto_str import auto_str +from modules.common.consumer_setup import ConsumerSetup +from ..vendor import vendor_descriptor + + +@auto_str +class MitsubishiModbusConfiguration: + def __init__(self, + ip_address: Optional[str] = None, + port: int = 502, + modbus_id: int = 1): + self.ip_address = ip_address + self.port = port + self.modbus_id = modbus_id + + +@auto_str +class MitsubishiModbus(ConsumerSetup[MitsubishiModbusConfiguration]): + def __init__(self, + name: str = "Mitsubishi Electric Klimaanlage (Intesis Modbus-Interface)", + type: str = "mitsubishi_modbus", + id: int = 0, + configuration: Optional[MitsubishiModbusConfiguration] = None, + usage: Tuple[ConsumerUsage, ...] = (ConsumerUsage.SUSPENDABLE_ONOFF,), + **kwargs) -> None: + super().__init__(name, type, id, vendor=vendor_descriptor.configuration_factory( + ).type, configuration=configuration or MitsubishiModbusConfiguration(), usage=usage, **kwargs) diff --git a/packages/modules/consumers/mitsubishi/mitsubishi_modbus/consumer.py b/packages/modules/consumers/mitsubishi/mitsubishi_modbus/consumer.py new file mode 100644 index 0000000000..a592611d16 --- /dev/null +++ b/packages/modules/consumers/mitsubishi/mitsubishi_modbus/consumer.py @@ -0,0 +1,37 @@ +#!/usr/bin/env python3 +import logging +from typing import Optional + +from modules.common.abstract_device import DeviceDescriptor +from modules.common.configurable_consumer import ConfigurableConsumer +from modules.common.modbus import ModbusDataType, ModbusTcpClient_ +from modules.consumers.mitsubishi.mitsubishi_modbus.config import MitsubishiModbus + +log = logging.getLogger(__name__) + + +def create_consumer(config: MitsubishiModbus): + client: Optional[ModbusTcpClient_] = None + + def initializer(): + nonlocal client + client = ModbusTcpClient_(config.configuration.ip_address, config.configuration.port) + + def switch_on() -> None: + # Reg 0: AC unit On/Off, 1 = On + client.write_register(0, 1, data_type=ModbusDataType.INT_16, unit=config.configuration.modbus_id) + + def switch_off() -> None: + # Reg 0: AC unit On/Off, 0 = Off + client.write_register(0, 0, data_type=ModbusDataType.INT_16, unit=config.configuration.modbus_id) + + # Keine update()-Funktion: das Intesis-Interface stellt keine Leistungsmessung zur Verfügung. + # Wer den Verbrauch erfassen will, braucht eine separate Leistungsmessung (zB + # Zwischenzähler/Smart-Plug), die diesem Verbraucher zugeordnet wird. + return ConfigurableConsumer(consumer_config=config, + initializer=initializer, + switch_on=switch_on, + switch_off=switch_off) + + +device_descriptor = DeviceDescriptor(configuration_factory=MitsubishiModbus) diff --git a/packages/modules/consumers/mitsubishi/vendor.py b/packages/modules/consumers/mitsubishi/vendor.py new file mode 100644 index 0000000000..6688774a09 --- /dev/null +++ b/packages/modules/consumers/mitsubishi/vendor.py @@ -0,0 +1,14 @@ +from pathlib import Path + +from modules.common.abstract_device import DeviceDescriptor +from modules.devices.vendors import VendorGroup + + +class Vendor: + def __init__(self): + self.type = Path(__file__).parent.name + self.vendor = "Mitsubishi Electric" + self.group = VendorGroup.VENDORS.value + + +vendor_descriptor = DeviceDescriptor(configuration_factory=Vendor) diff --git a/packages/modules/consumers/mtec/__init__.py b/packages/modules/consumers/mtec/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/modules/consumers/mtec/mtec/__init__.py b/packages/modules/consumers/mtec/mtec/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/modules/consumers/mtec/mtec/config.py b/packages/modules/consumers/mtec/mtec/config.py new file mode 100644 index 0000000000..c0da618a18 --- /dev/null +++ b/packages/modules/consumers/mtec/mtec/config.py @@ -0,0 +1,33 @@ +from typing import Optional, Tuple + +from control.consumer.consumer_data import ConsumerUsage +from helpermodules.auto_str import auto_str +from modules.common.consumer_setup import ConsumerSetup +from ..vendor import vendor_descriptor + + +@auto_str +class MtecConfiguration: + def __init__(self, + ip_address: Optional[str] = None, + port: int = 502, + modbus_id: int = 1): + self.ip_address = ip_address + self.port = port + self.modbus_id = modbus_id + + +@auto_str +class Mtec(ConsumerSetup[MtecConfiguration]): + def __init__(self, + name: str = "M-TEC Wärmepumpe", + type: str = "mtec", + id: int = 0, + configuration: Optional[MtecConfiguration] = None, + # Am Wärmepumpenregler muss "Modul Type" auf "Virtual" und "WMZ Modus" auf + # "Analog" gestellt werden, siehe M-TEC-Anleitung zur PV-Überschussnutzung. + usage: Tuple[ConsumerUsage, ...] = (ConsumerUsage.SUSPENDABLE_TUNABLE, + ConsumerUsage.METER_ONLY), + **kwargs) -> None: + super().__init__(name, type, id, vendor=vendor_descriptor.configuration_factory( + ).type, configuration=configuration or MtecConfiguration(), usage=usage, **kwargs) diff --git a/packages/modules/consumers/mtec/mtec/consumer.py b/packages/modules/consumers/mtec/mtec/consumer.py new file mode 100644 index 0000000000..277c18dc57 --- /dev/null +++ b/packages/modules/consumers/mtec/mtec/consumer.py @@ -0,0 +1,47 @@ +#!/usr/bin/env python3 +import logging +from typing import Optional + +from modules.common.abstract_device import DeviceDescriptor +from modules.common.component_state import ConsumerState +from modules.common.component_type import ComponentType +from modules.common.configurable_consumer import ConfigurableConsumer, SetLimitData +from modules.common.modbus import ModbusDataType, ModbusTcpClient_ +from modules.common.simcount._simcounter import SimCounterConsumer +from modules.consumers.mtec.mtec.config import Mtec + +log = logging.getLogger(__name__) + + +def create_consumer(config: Mtec): + client: Optional[ModbusTcpClient_] = None + sim_counter: Optional[SimCounterConsumer] = None + + def initializer(): + nonlocal client, sim_counter + client = ModbusTcpClient_(config.configuration.ip_address, config.configuration.port) + sim_counter = SimCounterConsumer(config.id, ComponentType.CONSUMER) + + def update() -> ConsumerState: + # Reg 707: elektrische Leistungsaufnahme [W] + power = client.read_holding_registers(707, ModbusDataType.INT_16, unit=config.configuration.modbus_id) + imported, exported = sim_counter.sim_count(power) + + return ConsumerState( + power=power, + imported=imported, + exported=exported + ) + + def set_power_limit(power_limit: Optional[float], data: SetLimitData) -> None: + # Reg 1000: PV-Überschussleistung [W] + value = 0 if power_limit is None else max(round(power_limit), 0) + client.write_register(1000, value, data_type=ModbusDataType.INT_16, unit=config.configuration.modbus_id) + + return ConfigurableConsumer(consumer_config=config, + initializer=initializer, + update=update, + set_power_limit=set_power_limit) + + +device_descriptor = DeviceDescriptor(configuration_factory=Mtec) diff --git a/packages/modules/consumers/mtec/vendor.py b/packages/modules/consumers/mtec/vendor.py new file mode 100644 index 0000000000..eba3646172 --- /dev/null +++ b/packages/modules/consumers/mtec/vendor.py @@ -0,0 +1,14 @@ +from pathlib import Path + +from modules.common.abstract_device import DeviceDescriptor +from modules.devices.vendors import VendorGroup + + +class Vendor: + def __init__(self): + self.type = Path(__file__).parent.name + self.vendor = "M-TEC" + self.group = VendorGroup.VENDORS.value + + +vendor_descriptor = DeviceDescriptor(configuration_factory=Vendor) diff --git a/packages/modules/consumers/weishaupt/__init__.py b/packages/modules/consumers/weishaupt/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/modules/consumers/weishaupt/vendor.py b/packages/modules/consumers/weishaupt/vendor.py new file mode 100644 index 0000000000..3623816d5c --- /dev/null +++ b/packages/modules/consumers/weishaupt/vendor.py @@ -0,0 +1,14 @@ +from pathlib import Path + +from modules.common.abstract_device import DeviceDescriptor +from modules.devices.vendors import VendorGroup + + +class Vendor: + def __init__(self): + self.type = Path(__file__).parent.name + self.vendor = "Weishaupt" + self.group = VendorGroup.VENDORS.value + + +vendor_descriptor = DeviceDescriptor(configuration_factory=Vendor) diff --git a/packages/modules/consumers/weishaupt/weishaupt_wwp/__init__.py b/packages/modules/consumers/weishaupt/weishaupt_wwp/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/modules/consumers/weishaupt/weishaupt_wwp/config.py b/packages/modules/consumers/weishaupt/weishaupt_wwp/config.py new file mode 100644 index 0000000000..dbc4bdef2d --- /dev/null +++ b/packages/modules/consumers/weishaupt/weishaupt_wwp/config.py @@ -0,0 +1,33 @@ +from typing import Optional, Tuple + +from control.consumer.consumer_data import ConsumerUsage +from helpermodules.auto_str import auto_str +from modules.common.consumer_setup import ConsumerSetup +from ..vendor import vendor_descriptor + + +@auto_str +class WeishauptConfiguration: + def __init__(self, + ip_address: Optional[str] = None, + port: int = 502, + modbus_id: int = 1): + self.ip_address = ip_address + self.port = port + self.modbus_id = modbus_id + + +@auto_str +class Weishaupt(ConsumerSetup[WeishauptConfiguration]): + def __init__(self, + name: str = "Weishaupt Wärmepumpe (AEROBLOCK/BIBLOCK/SPLITBLOCK/GEOBLOCK)", + type: str = "weishaupt_wwp", + id: int = 0, + configuration: Optional[WeishauptConfiguration] = None, + # kein SUSPENDABLE_ONOFF: die SG-Ready-Eingänge sind auf diesen Geräten laut + # Weishaupt-Datenpunktliste (Modbus TCP WWP) nur lesbar, die Leistungsvorgabe + # SollwertPV übersteuert SG-Ready ohnehin. + usage: Tuple[ConsumerUsage, ...] = (ConsumerUsage.SUSPENDABLE_TUNABLE,), + **kwargs) -> None: + super().__init__(name, type, id, vendor=vendor_descriptor.configuration_factory( + ).type, configuration=configuration or WeishauptConfiguration(), usage=usage, **kwargs) diff --git a/packages/modules/consumers/weishaupt/weishaupt_wwp/consumer.py b/packages/modules/consumers/weishaupt/weishaupt_wwp/consumer.py new file mode 100644 index 0000000000..39ee68b701 --- /dev/null +++ b/packages/modules/consumers/weishaupt/weishaupt_wwp/consumer.py @@ -0,0 +1,34 @@ +#!/usr/bin/env python3 +import logging +from typing import Optional + +from modules.common.abstract_device import DeviceDescriptor +from modules.common.configurable_consumer import ConfigurableConsumer, SetLimitData +from modules.common.modbus import ModbusDataType, ModbusTcpClient_ +from modules.consumers.weishaupt.weishaupt_wwp.config import Weishaupt + +log = logging.getLogger(__name__) + + +def create_consumer(config: Weishaupt): + client: Optional[ModbusTcpClient_] = None + + def initializer(): + nonlocal client + client = ModbusTcpClient_(config.configuration.ip_address, config.configuration.port) + + def set_power_limit(power_limit: Optional[float], data: SetLimitData) -> None: + # Reg 40002: SollwertPV [W], 0 gibt die Regelung an die Wärmepumpe zurück (0..65535, + # daher clamp statt negativer Werte). + value = 0 if power_limit is None else min(max(round(power_limit), 0), 65535) + client.write_register(40002, value, data_type=ModbusDataType.UINT_16, unit=config.configuration.modbus_id) + + # Keine update()-Funktion: die Datenpunktliste (83807301) enthält keine elektrische + # Leistungsaufnahme, nur Leistungsanforderung in % (Reg 33103). Wer den Verbrauch erfassen + # will, braucht eine separate Leistungsmessung (zB Zwischenzähler/Smart-Plug). + return ConfigurableConsumer(consumer_config=config, + initializer=initializer, + set_power_limit=set_power_limit) + + +device_descriptor = DeviceDescriptor(configuration_factory=Weishaupt) diff --git a/packages/modules/consumers/xtherma/__init__.py b/packages/modules/consumers/xtherma/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/modules/consumers/xtherma/vendor.py b/packages/modules/consumers/xtherma/vendor.py new file mode 100644 index 0000000000..a311eb1574 --- /dev/null +++ b/packages/modules/consumers/xtherma/vendor.py @@ -0,0 +1,14 @@ +from pathlib import Path + +from modules.common.abstract_device import DeviceDescriptor +from modules.devices.vendors import VendorGroup + + +class Vendor: + def __init__(self): + self.type = Path(__file__).parent.name + self.vendor = "Xtherma" + self.group = VendorGroup.VENDORS.value + + +vendor_descriptor = DeviceDescriptor(configuration_factory=Vendor) diff --git a/packages/modules/consumers/xtherma/xtherma/__init__.py b/packages/modules/consumers/xtherma/xtherma/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/modules/consumers/xtherma/xtherma/config.py b/packages/modules/consumers/xtherma/xtherma/config.py new file mode 100644 index 0000000000..8ecf8e7a84 --- /dev/null +++ b/packages/modules/consumers/xtherma/xtherma/config.py @@ -0,0 +1,32 @@ +from typing import Optional, Tuple + +from control.consumer.consumer_data import ConsumerUsage +from helpermodules.auto_str import auto_str +from modules.common.consumer_setup import ConsumerSetup +from ..vendor import vendor_descriptor + + +@auto_str +class XthermaConfiguration: + def __init__(self, + ip_address: Optional[str] = None, + port: int = 502, + modbus_id: int = 1): + self.ip_address = ip_address + self.port = port + self.modbus_id = modbus_id + + +@auto_str +class Xtherma(ConsumerSetup[XthermaConfiguration]): + def __init__(self, + name: str = "Xtherma Wärmepumpe", + type: str = "xtherma", + id: int = 0, + configuration: Optional[XthermaConfiguration] = None, + usage: Tuple[ConsumerUsage, ...] = (ConsumerUsage.SUSPENDABLE_TUNABLE, + ConsumerUsage.SUSPENDABLE_ONOFF, + ConsumerUsage.METER_ONLY), + **kwargs) -> None: + super().__init__(name, type, id, vendor=vendor_descriptor.configuration_factory( + ).type, configuration=configuration or XthermaConfiguration(), usage=usage, **kwargs) diff --git a/packages/modules/consumers/xtherma/xtherma/consumer.py b/packages/modules/consumers/xtherma/xtherma/consumer.py new file mode 100644 index 0000000000..e1456fd1d4 --- /dev/null +++ b/packages/modules/consumers/xtherma/xtherma/consumer.py @@ -0,0 +1,80 @@ +#!/usr/bin/env python3 +import logging +from typing import Optional + +from helpermodules import timecheck +from modules.common.abstract_device import DeviceDescriptor +from modules.common.component_state import ConsumerState +from modules.common.component_type import ComponentType +from modules.common.configurable_consumer import ConfigurableConsumer, SetLimitData +from modules.common.modbus import ModbusDataType, ModbusTcpClient_ +from modules.common.simcount._simcounter import SimCounterConsumer +from modules.consumers.xtherma.xtherma.config import Xtherma + +log = logging.getLogger(__name__) + + +def create_consumer(config: Xtherma): + client: Optional[ModbusTcpClient_] = None + sim_counter: Optional[SimCounterConsumer] = None + last_write: Optional[float] = None + last_sgready_write: Optional[float] = None + + def initializer(): + nonlocal client, sim_counter + client = ModbusTcpClient_(config.configuration.ip_address, config.configuration.port) + sim_counter = SimCounterConsumer(config.id, ComponentType.CONSUMER) + + def update() -> ConsumerState: + # Reg 177 (in_total): elektrische Leistungsaufnahme, Auflösung 10W + power = client.read_holding_registers( + 177, ModbusDataType.UINT_16, unit=config.configuration.modbus_id) * 10 + imported, exported = sim_counter.sim_count(power) + + return ConsumerState( + power=power, + imported=imported, + exported=exported + ) + + def set_power_limit(power_limit: Optional[float], data: SetLimitData) -> None: + # Reg 70/71 dürfen laut Xtherma-Protokoll höchstens 1x/min beschrieben werden. + nonlocal last_write + if last_write is not None and timecheck.check_timestamp(last_write, 60): + return + last_write = timecheck.create_timestamp() + unit = config.configuration.modbus_id + if power_limit is None: + client.write_register(70, 0, data_type=ModbusDataType.UINT_16, unit=unit) + else: + # Reg 71: Überschuss [W], 0..100000 + client.write_register( + 71, min(max(round(power_limit), 0), 100000), data_type=ModbusDataType.UINT_16, unit=unit) + client.write_register(70, 1, data_type=ModbusDataType.UINT_16, unit=unit) + + def switch_on() -> None: + # Reg 60: SG-Ready-Modus, darf laut Xtherma-Protokoll höchstens 1x/30min beschrieben + # werden. 1 = Normalbetrieb. + nonlocal last_sgready_write + if last_sgready_write is not None and timecheck.check_timestamp(last_sgready_write, 1800): + return + last_sgready_write = timecheck.create_timestamp() + client.write_register(60, 1, data_type=ModbusDataType.UINT_16, unit=config.configuration.modbus_id) + + def switch_off() -> None: + # Reg 60: SG-Ready-Modus, 2 = Sperre. + nonlocal last_sgready_write + if last_sgready_write is not None and timecheck.check_timestamp(last_sgready_write, 1800): + return + last_sgready_write = timecheck.create_timestamp() + client.write_register(60, 2, data_type=ModbusDataType.UINT_16, unit=config.configuration.modbus_id) + + return ConfigurableConsumer(consumer_config=config, + initializer=initializer, + update=update, + set_power_limit=set_power_limit, + switch_on=switch_on, + switch_off=switch_off) + + +device_descriptor = DeviceDescriptor(configuration_factory=Xtherma) diff --git a/packages/modules/devices/avm/avm/device.py b/packages/modules/devices/avm/avm/device.py index 4c370a9ac0..5103d993db 100644 --- a/packages/modules/devices/avm/avm/device.py +++ b/packages/modules/devices/avm/avm/device.py @@ -50,7 +50,7 @@ def get_session_id(): challengeResponse = ET.fromstring(response.content) session_id = challengeResponse.find('SID').text if session_id != INVALID_SESSIONID: - return + return session_id blockTimeXML = challengeResponse.find('BlockTime') if blockTimeXML is not None and int(blockTimeXML.text) > 0: raise Exception("Durch Anmeldefehler in der Vergangenheit ist der Zugang zur FRITZ!Box " diff --git a/packages/modules/devices/sonnen/sonnenbatterie/api.py b/packages/modules/devices/sonnen/sonnenbatterie/api.py index 5689b6289f..d908d6f0f8 100644 --- a/packages/modules/devices/sonnen/sonnenbatterie/api.py +++ b/packages/modules/devices/sonnen/sonnenbatterie/api.py @@ -133,9 +133,12 @@ class JsonApiVersion(Enum): class JsonApi(): class OperatingMode(Enum): + # only define modes we can override savely MANUAL = "1" SELF_CONSUMPTION = "2" + # MODULE_EXTENSION = "6" TIME_OF_USE = "10" + # OPTIMIZATION = "11" class PowerMeterDirection(Enum): PRODUCTION = "production" @@ -447,12 +450,18 @@ def set_power_limit(self, power_limit: Optional[int]) -> None: configurations = self.__get_configurations() if "EM_OperatingMode" not in configurations: raise KeyError("The key 'EM_OperatingMode' is missing in the API response.") + try: + self.OperatingMode(configurations["EM_OperatingMode"]) + except ValueError: + raise ValueError("Aktive Speichersteuerung wird in " + f"Betriebsmodus {configurations['EM_OperatingMode']} " + "nicht unterstützt.") if self.default_operating_mode is None: # Store the default operating mode for later restoration self.default_operating_mode = self.OperatingMode(configurations["EM_OperatingMode"]) log.debug(f"default_operating_mode set to: {self.default_operating_mode}") - operating_mode = self.OperatingMode(configurations["EM_OperatingMode"]) + if power_limit is None: # No specific power limit is set, activating default mode to allow the system to optimize energy usage by it # self. diff --git a/packages/modules/devices/tesla/tesla/counter.py b/packages/modules/devices/tesla/tesla/counter.py index 8840d2e70b..586214e9dd 100644 --- a/packages/modules/devices/tesla/tesla/counter.py +++ b/packages/modules/devices/tesla/tesla/counter.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 import logging -from requests import HTTPError +import math from modules.common.abstract_device import AbstractCounter from modules.common.component_state import CounterState @@ -18,33 +18,140 @@ def __init__(self, component_config: TeslaCounterSetup) -> None: self.component_config = component_config def initialize(self) -> None: - self.store = get_component_value_store(self.component_config.type, self.component_config.id) - self.fault_state = FaultState(ComponentInfo.from_component_config(self.component_config)) + self.store = get_component_value_store( + self.component_config.type, + self.component_config.id, + ) + self.fault_state = FaultState( + ComponentInfo.from_component_config(self.component_config) + ) + + @staticmethod + def _nearly_zero(x: float, eps: float = 1e-9) -> bool: + return abs(x) < eps + + def _calc_currents_and_pf_from_pqu( + self, + voltages: list[float], + p_list: list[float], + q_list: list[float], + ) -> tuple[list[float], list[float]]: + """ + Calculates signed currents (A) and signed power factors per phase from P/Q/U. + + Convention: + - sign of current follows sign of active power P (import +, export -) + - PF = P / S (signed) + - S = sqrt(P^2 + Q^2) + - I = S / U (signed via P) + """ + currents: list[float] = [0.0, 0.0, 0.0] + pfs: list[float] = [0.0, 0.0, 0.0] + + for i in range(3): + u = voltages[i] + p = p_list[i] + q = q_list[i] + + if self._nearly_zero(u): + currents[i] = 0.0 + pfs[i] = 0.0 + continue + + s = math.sqrt(p * p + q * q) + + if self._nearly_zero(s): + currents[i] = 0.0 + pfs[i] = 0.0 + continue + + pfs[i] = p / s + i_mag = s / u + currents[i] = i_mag if p >= 0 else -i_mag + + return currents, pfs def update(self, client: PowerwallHttpClient, aggregate): - # read firmware version - status = client.get_json("/api/status") - log.debug('Firmware: ' + status["version"]) try: - # read additional info if firmware supports meters_site = client.get_json("/api/meters/site") + cached = meters_site[0]["Cached_readings"] + + voltages = [ + float(cached[f"v_l{phase}n"]) + for phase in range(1, 4) + ] + + p_list = [ + float(cached[f"real_power_{phase}"]) + for phase in ["a", "b", "c"] + ] + + q_list = [ + float(cached[f"reactive_power_{phase}"]) + for phase in ["a", "b", "c"] + ] + + api_currents = [ + float(cached[f"i_{phase}_current"]) + for phase in ["a", "b", "c"] + ] + + imported = aggregate["site"]["energy_imported"] + exported = aggregate["site"]["energy_exported"] + + calculated_currents, power_factors = ( + self._calc_currents_and_pf_from_pqu( + voltages=voltages, + p_list=p_list, + q_list=q_list, + ) + ) + + if all(self._nearly_zero(i) for i in api_currents): + currents = calculated_currents + log.debug( + "Tesla/Neurio phase currents missing (all 0). " + "Calculated currents locally from P/Q and U." + ) + else: + currents = api_currents + log.debug( + "Using phase currents from Tesla/Neurio API." + ) + + frequency = float(aggregate["site"]["frequency"]) + + serial = cached.get("serial_number") + serial_number = str(serial) if serial else None + powerwall_state = CounterState( - imported=aggregate["site"]["energy_imported"], - exported=aggregate["site"]["energy_exported"], + imported=imported, + exported=exported, power=aggregate["site"]["instant_power"], - voltages=[meters_site[0]["Cached_readings"]["v_l" + str(phase) + "n"] for phase in range(1, 4)], - currents=[meters_site[0]["Cached_readings"]["i_" + phase + "_current"] for phase in ["a", "b", "c"]], - powers=[meters_site[0]["Cached_readings"]["real_power_" + phase] for phase in ["a", "b", "c"]] + voltages=voltages, + currents=currents, + powers=p_list, + power_factors=power_factors, + frequency=round(frequency, 2), + serial_number=serial_number, ) - except (KeyError, HTTPError): + + except (KeyError, IndexError, TypeError, ValueError) as e: log.debug( - "Firmware seems not to provide detailed phase measurements. Fallback to total power only.") + "Firmware seems not to provide detailed phase measurements. " + "Fallback to total power only. (%s)", + str(e), + ) + powerwall_state = CounterState( imported=aggregate["site"]["energy_imported"], exported=aggregate["site"]["energy_exported"], - power=aggregate["site"]["instant_power"] + power=aggregate["site"]["instant_power"], ) + self.store.set(powerwall_state) -component_descriptor = ComponentDescriptor(configuration_factory=TeslaCounterSetup) +component_descriptor = ComponentDescriptor( + configuration_factory=TeslaCounterSetup +) diff --git a/packages/modules/devices/tesla/tesla/device.py b/packages/modules/devices/tesla/tesla/device.py index 3a219cc1fe..590089c798 100644 --- a/packages/modules/devices/tesla/tesla/device.py +++ b/packages/modules/devices/tesla/tesla/device.py @@ -2,14 +2,23 @@ import logging import requests from requests import HTTPError -from typing import Iterable, Union +from typing import Iterable, Optional, Union from modules.common.abstract_device import DeviceDescriptor from modules.common.component_context import SingleComponentUpdateContext -from modules.common.configurable_device import ComponentFactoryByType, ConfigurableDevice, MultiComponentUpdater +from modules.common.configurable_device import ( + ComponentFactoryByType, + ConfigurableDevice, + MultiComponentUpdater, +) from modules.common.req import get_http_session from modules.devices.tesla.tesla.bat import TeslaBat -from modules.devices.tesla.tesla.config import Tesla, TeslaBatSetup, TeslaCounterSetup, TeslaInverterSetup +from modules.devices.tesla.tesla.config import ( + Tesla, + TeslaBatSetup, + TeslaCounterSetup, + TeslaInverterSetup, +) from modules.devices.tesla.tesla.counter import TeslaCounter from modules.devices.tesla.tesla.http_client import PowerwallHttpClient from modules.devices.tesla.tesla.inverter import TeslaInverter @@ -17,31 +26,47 @@ log = logging.getLogger(__name__) -def __update_components(client: PowerwallHttpClient, - components: Iterable[Union[TeslaBat, TeslaCounter, TeslaInverter]]): +def __update_components( + client: PowerwallHttpClient, + components: Iterable[Union[TeslaBat, TeslaCounter, TeslaInverter]], +): aggregate = client.get_json("/api/meters/aggregates") + for component in components: with SingleComponentUpdateContext(component.fault_state): component.update(client, aggregate) -def _authenticate(session: requests.Session, url: str, email: str, password: str): +def _authenticate( + session: requests.Session, + url: str, + email: str, + password: str, +): """ email is not yet required for login (2022/01), but we simulate the whole login page """ response = session.post( "https://" + url + "/api/login/Basic", - json={"username": "customer", "email": email, "password": password, "force_sm_off": False}, + json={ + "username": "customer", + "email": email, + "password": password, + "force_sm_off": False, + }, verify=False, - timeout=5 + timeout=5, ) - log.debug("Authentication endpoint send cookies %s", str(response.cookies)) - return {"AuthCookie": response.cookies["AuthCookie"], "UserRecord": response.cookies["UserRecord"]} + + return { + "AuthCookie": response.cookies["AuthCookie"], + "UserRecord": response.cookies["UserRecord"], + } def create_device(device_config: Tesla): - http_client = None - session = None + http_client: Optional[PowerwallHttpClient] = None + session: Optional[requests.Session] = None def create_bat_component(component_config: TeslaBatSetup): return TeslaBat(component_config) @@ -52,47 +77,103 @@ def create_counter_component(component_config: TeslaCounterSetup): def create_inverter_component(component_config: TeslaInverterSetup): return TeslaInverter(component_config) - def update_components(components: Iterable[Union[TeslaBat, TeslaCounter, TeslaInverter]]): + def update_components( + components: Iterable[Union[TeslaBat, TeslaCounter, TeslaInverter]], + ): log.debug("Beginning update") + address = device_config.configuration.ip_address email = device_config.configuration.email password = device_config.configuration.password if http_client.cookies is None: - http_client.cookies = _authenticate(session, address, email, password) + http_client.cookies = _authenticate( + session, + address, + email, + password, + ) __update_components(http_client, components) return + try: __update_components(http_client, components) return except HTTPError as e: - if e.response.status_code != 401 and e.response.status_code != 403: - raise e - log.warning("Login to powerwall with existing cookie failed. Will retry with new cookie...") - http_client.cookies = _authenticate(session, address, email, password) + status = getattr( + getattr(e, "response", None), + "status_code", + None, + ) + + if status not in (401, 403): + raise + + log.warning( + "Login to powerwall with existing cookie failed " + "(status=%s). Will retry with new cookie...", + status, + ) + + http_client.cookies = _authenticate( + session, + address, + email, + password, + ) __update_components(http_client, components) - log.debug("Update completed successfully") def initializer(): nonlocal http_client, session + + address = device_config.configuration.ip_address + email = device_config.configuration.email + password = device_config.configuration.password + session = get_http_session() - http_client = PowerwallHttpClient(device_config.configuration.ip_address, session, None) + + http_client = PowerwallHttpClient( + address, + session, + None, + ) + + http_client.cookies = _authenticate( + session, + address, + email, + password, + ) + + try: + status = http_client.get_json("/api/status") + log.debug("Firmware: %s", status["version"]) + except (KeyError, requests.RequestException, ValueError): + log.debug( + "Could not read Tesla Powerwall firmware version.", + exc_info=True, + ) return ConfigurableDevice( device_config=device_config, initializer=initializer, + error_handler=initializer, component_factory=ComponentFactoryByType( bat=create_bat_component, counter=create_counter_component, inverter=create_inverter_component, ), - component_updater=MultiComponentUpdater(update_components) + component_updater=MultiComponentUpdater(update_components), ) device_descriptor = DeviceDescriptor( configuration_factory=Tesla, - compatibility_device_note="Tesla hat die lokale API Anfang 2025 mittels Firmwareupdate abgeschaltet.\nFür " - "Neuanlagen muss aktuell auf unsere Zählerkits zurückgegriffen werden.", - special_icon="ℹ️" + compatibility_device_note=( + "Tesla hat die lokale API mittels Firmwareupdate eingeschränkt.\n" + "Je nach Modell und Firmware-Version kann die Verwendung unserer " + "Zähler-Kits erforderlich sein, falls die direkte Anbindung nicht " + "funktioniert." + ), + special_icon="ℹ️", ) diff --git a/packages/modules/devices/tesla/tesla/http_client.py b/packages/modules/devices/tesla/tesla/http_client.py index f02eb15094..0d45803119 100644 --- a/packages/modules/devices/tesla/tesla/http_client.py +++ b/packages/modules/devices/tesla/tesla/http_client.py @@ -8,5 +8,10 @@ def __init__(self, host: str, session: requests.Session, cookies): self.__session = session def get_json(self, relative_url: str): - url = self.__base_url + relative_url - return self.__session.get(url, cookies=self.cookies, verify=False, timeout=5).json() + response = self.__session.get( + self.__base_url + relative_url, + cookies=self.cookies, + verify=False, + timeout=5, + ) + return response.json() diff --git a/packages/modules/devices/tesla/tesla/tesla.php b/packages/modules/devices/tesla/tesla/tesla.php index ec396b638e..b428767d68 100644 --- a/packages/modules/devices/tesla/tesla/tesla.php +++ b/packages/modules/devices/tesla/tesla/tesla.php @@ -333,7 +333,7 @@ function teslaLogin () { ?>