From 76c369ef314a13e0c7fe08be0e3629d6d7957620 Mon Sep 17 00:00:00 2001 From: buhito81 <101875447+buhito81@users.noreply.github.com> Date: Wed, 14 Oct 2026 19:17:51 +0200 Subject: [PATCH 1/3] Ladeprotokoll: Netzbezug und Kosten je Tarifzeitraum (Feste Tarifstunden) Co-Authored-By: Claude Opus 5.5 --- packages/control/chargelog/chargelog.py | 26 ++++++- packages/control/chargelog/chargelog_test.py | 68 ++++++++++++++++++ .../control/chargelog/process_chargelog.py | 24 ++++++- .../chargelog/process_chargelog_test.py | 30 ++++++++ .../control/chargepoint/chargepoint_data.py | 2 + packages/control/optional.py | 20 ++++++ packages/control/optional_test.py | 65 +++++++++++++++++ .../measurement_logging/process_log.py | 1 + .../measurement_logging/write_log.py | 6 +- .../measurement_logging/write_log_test.py | 23 +++++++ packages/helpermodules/update_config.py | 12 +++- packages/helpermodules/update_config_test.py | 18 +++++ .../flexible_tariffs/fixed_hours/tariff.py | 35 +++++++--- .../fixed_hours/tariff_test.py | 69 +++++++++++++++++++ web/settings/downloadChargeLog.php | 28 ++++++++ 15 files changed, 414 insertions(+), 13 deletions(-) create mode 100644 packages/control/chargelog/process_chargelog_test.py diff --git a/packages/control/chargelog/chargelog.py b/packages/control/chargelog/chargelog.py index 6468f55c8c..405939e663 100644 --- a/packages/control/chargelog/chargelog.py +++ b/packages/control/chargelog/chargelog.py @@ -54,7 +54,9 @@ # "grid": 0.25, # "pv": 0.25, # "bat": 0.5, -# "cp": 0} +# "cp": 0}, +# "tariff_split": { # optional, nur bei Festen Tarifstunden: Netzanteil je Tarifzeitraum +# "NT": {"energy": 1000, "costs": 0.25}} # } # } log = logging.getLogger("chargelog") @@ -298,6 +300,9 @@ def _create_entry(chargepoint, charging_ev): "power_source": energy_source } } + tariff_split = get_value_or_default(lambda: _get_tariff_split(log_data), {}) + if tariff_split: + new_entry["data"]["tariff_split"] = tariff_split return new_entry @@ -341,10 +346,29 @@ def calc_energy_costs(cp, create_log_entry: bool = False): else: costs = _calc_costs(charged_energy_by_source, reference_entries[-1]["prices"]) cp.data.set.log.costs += costs + _add_tariff_split(cp.data.set.log, charged_energy_by_source, reference_entries[-1]["prices"]) except Exception: log.exception(f"Fehler beim Berechnen der Ladekosten für Ladepunkt {cp.num}") +def _add_tariff_split(log_data, charged_energy_by_source: Dict[str, float], prices: Dict) -> None: + """ Netzanteil des Intervalls dem Tarifzeitraum (Feste Tarifstunden) zuordnen, mit demselben Preis wie + _calc_costs. Ohne Tarifname (dynamische Tarife, kein Preismodul) bleibt es bei der Summe in costs. + """ + tariff_name = prices.get("tariff_name") + grid_energy = charged_energy_by_source["grid"] + if tariff_name is None or grid_energy <= 0: + return + tariff = log_data.tariff_split.setdefault(tariff_name, {"energy": 0, "costs": 0}) + tariff["energy"] += grid_energy + tariff["costs"] += grid_energy * prices["grid"] + + +def _get_tariff_split(log_data) -> Dict[str, Dict[str, float]]: + return {name: {"energy": round(tariff["energy"], 2), "costs": round(tariff["costs"], 2)} + for name, tariff in log_data.tariff_split.items()} + + def calculate_charged_energy_by_source(cp, processed_entries, reference_entries, create_log_entry: bool = False): try: reference = _get_reference_position(cp, create_log_entry) diff --git a/packages/control/chargelog/chargelog_test.py b/packages/control/chargelog/chargelog_test.py index 6f4ce60afb..3b25c0b553 100644 --- a/packages/control/chargelog/chargelog_test.py +++ b/packages/control/chargelog/chargelog_test.py @@ -170,3 +170,71 @@ def test_calc_charge_cost_reference_middle_day_change(mock_data, monkeypatch): assert cp.data.set.log.charged_energy_by_source == { 'grid': 1242.8, 'pv': 385.8, 'bat': 671.4, 'cp': 0.0} assert round(cp.data.set.log.costs, 5) == 0.5 + + +def test_calc_charge_cost_tariff_split(mock_data, monkeypatch): + cp = Chargepoint(4, None) + cp.data.set.log.imported_since_plugged = cp.data.set.log.imported_since_mode_switch = 3950 + cp.data.set.log.timestamp_mode_switch = 1652682600 # 8:30 + cp.data.get.imported = 4050 + cp.data.set.log.charged_energy_by_source = {'bat': 100, 'cp': 0, 'grid': 100, 'pv': 100} + cp.data.set.log.tariff_split = {"NT": {"energy": 1000, "costs": 0.25}, + "Standardpreis": {"energy": 100, "costs": 0.03}} + daily_log = mock_daily_log(monkeypatch) + daily_log["entries"][-1]["prices"]["tariff_name"] = "NT" + + with patch("builtins.open", mock_open(read_data=json.dumps(daily_log))): + calc_energy_costs(cp) + + # nur der Netzanteil dieses Intervalls (1242.8 - 100 Wh) mit dem Netzpreis dieses Intervalls + assert round(cp.data.set.log.tariff_split["NT"]["energy"], 2) == 2142.8 + assert round(cp.data.set.log.tariff_split["NT"]["costs"], 5) == round(0.25 + 1142.8 * 0.0003, 5) + assert cp.data.set.log.tariff_split["Standardpreis"] == {"energy": 100, "costs": 0.03} + assert round(cp.data.set.log.costs, 5) == 0.5 + + +@pytest.mark.parametrize("tariff_name", [pytest.param(None, id="dynamischer Tarif"), + pytest.param("missing", id="alter Tageslog-Eintrag")]) +def test_calc_charge_cost_without_tariff_name(tariff_name, mock_data, monkeypatch): + cp = Chargepoint(4, None) + cp.data.set.log.imported_since_plugged = cp.data.set.log.imported_since_mode_switch = 3950 + cp.data.set.log.timestamp_mode_switch = 1652682600 # 8:30 + cp.data.get.imported = 4050 + cp.data.set.log.charged_energy_by_source = {'bat': 100, 'cp': 0, 'grid': 100, 'pv': 100} + daily_log = mock_daily_log(monkeypatch) + if tariff_name != "missing": + daily_log["entries"][-1]["prices"]["tariff_name"] = tariff_name + + with patch("builtins.open", mock_open(read_data=json.dumps(daily_log))): + calc_energy_costs(cp) + + assert cp.data.set.log.tariff_split == {} + assert round(cp.data.set.log.costs, 5) == 0.5 + + +@pytest.mark.parametrize("tariff_split, expected", + [pytest.param({}, None, id="ohne Aufteilung kein Schlüssel"), + pytest.param({"NT": {"energy": 18400.123, "costs": 4.73456}}, + {"NT": {"energy": 18400.12, "costs": 4.73}}, id="mit Aufteilung")]) +def test_create_entry_tariff_split(tariff_split, expected, mock_data, monkeypatch): + monkeypatch.setattr(chargelog, "calc_energy_costs", Mock()) + cp = Chargepoint(4, None) + cp.data.get.imported = 4050 + cp.data.set.log.imported_at_mode_switch = 100 + cp.data.set.log.timestamp_mode_switch = 1652682600 + cp.data.set.log.costs = 4.9 + cp.data.set.log.tariff_split = tariff_split + + entry = chargelog._create_entry(cp, Mock()) + + assert entry["data"]["costs"] == 4.9 + assert entry["data"].get("tariff_split") == expected + + +def test_reset_log_data_chargemode_switch_resets_tariff_split(mock_data): + cp = Chargepoint(4, None) + cp.data.set.log.tariff_split = {"NT": {"energy": 1000, "costs": 0.25}} + + cp.reset_log_data_chargemode_switch() + + assert cp.data.set.log.tariff_split == {} diff --git a/packages/control/chargelog/process_chargelog.py b/packages/control/chargelog/process_chargelog.py index 9349db3e26..a0a24b0239 100644 --- a/packages/control/chargelog/process_chargelog.py +++ b/packages/control/chargelog/process_chargelog.py @@ -1,7 +1,7 @@ import json import logging import pathlib -from typing import Dict +from typing import Dict, List from helpermodules import timecheck @@ -112,13 +112,33 @@ def get_sum(entry_name: str) -> float: power_sum = get_sum("power") costs_sum = get_sum("costs") power_sum = power_sum / len(log_data["entries"]) - return { + totals = { "time_charged": duration_sum, "range_charged": range_charged_sum, "imported_since_mode_switch": mode_sum, "power": power_sum, "costs": costs_sum, } + tariff_split_sum = get_tariff_split_sum(log_data["entries"]) + if tariff_split_sum: + totals["tariff_split"] = tariff_split_sum + return totals + + +def get_tariff_split_sum(entries: List[Dict]) -> Dict[str, Dict[str, float]]: + """ Netzanteil je Tarifzeitraum summieren. Einträge ohne Aufteilung (ältere Einträge, dynamische Tarife) werden + übersprungen, data.costs bleibt die maßgebliche Summe. + """ + tariff_split_sum = {} + for entry in entries: + try: + for name, tariff in (entry["data"].get("tariff_split") or {}).items(): + tariff_sum = tariff_split_sum.setdefault(name, {"energy": 0, "costs": 0}) + tariff_sum["energy"] = round(tariff_sum["energy"] + tariff.get("energy", 0), 2) + tariff_sum["costs"] = round(tariff_sum["costs"] + tariff.get("costs", 0), 2) + except Exception: + log.exception("Fehler beim Summieren der Tarifaufteilung") + return tariff_split_sum def _get_parent_file() -> pathlib.Path: diff --git a/packages/control/chargelog/process_chargelog_test.py b/packages/control/chargelog/process_chargelog_test.py new file mode 100644 index 0000000000..15775535e3 --- /dev/null +++ b/packages/control/chargelog/process_chargelog_test.py @@ -0,0 +1,30 @@ +from control.chargelog.process_chargelog import get_totals_of_filtered_log_data + + +def _entry(costs: float, tariff_split=None) -> dict: + data = {"range_charged": 10, "imported_since_mode_switch": 1000, "power": 1000, "costs": costs} + if tariff_split is not None: + data["tariff_split"] = tariff_split + return {"time": {"time_charged": "0:30"}, "data": data} + + +def test_totals_tariff_split(): + log_data = {"entries": [ + _entry(1.0), # Eintrag ohne Aufteilung (ältere Version, dynamischer Tarif) + _entry(2.0, {"NT": {"energy": 1000.1, "costs": 0.25}, "Standardpreis": {"energy": 500, "costs": 0.15}}), + _entry(3.0, {"NT": {"energy": 2000.2, "costs": 0.5}}), + _entry(4.0, {"NT": {"energy": None}}), # unbrauchbarer Eintrag wird übersprungen + ]} + + totals = get_totals_of_filtered_log_data(log_data) + + assert totals["costs"] == 10.0 + assert totals["tariff_split"] == {"NT": {"energy": 3000.3, "costs": 0.75}, + "Standardpreis": {"energy": 500, "costs": 0.15}} + + +def test_totals_without_tariff_split(): + totals = get_totals_of_filtered_log_data({"entries": [_entry(1.0), _entry(2.0)]}) + + assert totals["costs"] == 3.0 + assert "tariff_split" not in totals diff --git a/packages/control/chargepoint/chargepoint_data.py b/packages/control/chargepoint/chargepoint_data.py index 45bf8840cf..60b973824a 100644 --- a/packages/control/chargepoint/chargepoint_data.py +++ b/packages/control/chargepoint/chargepoint_data.py @@ -98,6 +98,8 @@ class Log: range_at_start: Optional[float] = None range_at_end: Optional[float] = None odometer: Optional[float] = None + # Netzanteil je Tarifzeitraum (Feste Tarifstunden): {Name: {"energy": Wh, "costs": €}} + tariff_split: Dict[str, Dict[str, float]] = field(default_factory=empty_dict_factory) def connected_vehicle_factory() -> ConnectedVehicle: diff --git a/packages/control/optional.py b/packages/control/optional.py index 975ca28567..41c1001e5d 100644 --- a/packages/control/optional.py +++ b/packages/control/optional.py @@ -16,10 +16,12 @@ from helpermodules.utils import thread_handler from modules.common.configurable_tariff import ConfigurableFlexibleTariff, ConfigurableGridFee from modules.common.configurable_monitoring import ConfigurableMonitoring +from modules.electricity_pricing.flexible_tariffs.fixed_hours.tariff import get_tariff_name_for_time_slot log = logging.getLogger(__name__) AS_EURO_PER_KWH = 1000.0 # Umrechnung von €/Wh in €/kWh MQTT_PREFIX = "openWB/set/optional/ep" +DEFAULT_TARIFF_NAME = "Standardpreis" class Optional(OcppMixin): @@ -180,6 +182,24 @@ def ep_get_current_price(self) -> float: else: raise Exception("Kein Anbieter für strompreisbasiertes Laden konfiguriert.") + def ep_get_current_tariff_name(self) -> TypingOptional[str]: + """ Name des Tarifzeitraums (Feste Tarifstunden) für denselben Zeitslot, dessen Preis ep_get_current_price() + liefert. None, wenn ein konfiguriertes Preismodul keine festen Tarifstunden hat (z.B. dynamische Tarife). + """ + try: + modules = [module for module in (self._flexible_tariff_module, self._grid_fee_module) + if module is not None] + if (self.data.electricity_pricing.configured is False or len(modules) == 0 or + any(module.config.type != "fixed_hours" for module in modules)): + return None + time_slot = datetime.fromtimestamp(self.__get_current_timeslot_start()) + return " / ".join(get_tariff_name_for_time_slot(module.config.configuration, time_slot, + DEFAULT_TARIFF_NAME) + for module in modules) + except Exception as e: + log.exception("Fehler beim Ermitteln des Tarifzeitraums: %s", e) + return None + def __calculate_price_timeslot_length(self, prices: dict) -> int: first_timestamps = sorted(list(prices.keys()))[:2] return float(first_timestamps[1]) - float(first_timestamps[0]) diff --git a/packages/control/optional_test.py b/packages/control/optional_test.py index f729577bca..315056a977 100644 --- a/packages/control/optional_test.py +++ b/packages/control/optional_test.py @@ -1,7 +1,10 @@ +import datetime from unittest.mock import Mock import pytest from helpermodules import timecheck from control.optional import Optional +from modules.electricity_pricing.flexible_tariffs.fixed_hours.config import (FixedHoursTariff, + FixedHoursTariffConfiguration) ONE_HOUR_SECONDS = 3600 @@ -487,3 +490,65 @@ def test_et_price_update_required(monkeypatch, flexible_tariff, grid_fee, curren # evaluation assert result == expected + + +def _fixed_hours_module(name: str, times: list) -> Mock: + return Mock(config=FixedHoursTariff(configuration=FixedHoursTariffConfiguration( + default_price=0.0003, + tariffs=[{"name": name, "price": 0.0002, + "active_times": {"dates": [("01-01", "31-12")], "times": times, "weekdays": [0, 1, 2, 3, 4]}}]))) + + +NT = _fixed_hours_module("NT", [("00:00", "07:00"), ("23:00", "24:00")]) +MODULE_3 = _fixed_hours_module("Modul 3 NT", [("22:00", "24:00")]) +DYNAMIC = Mock(config=Mock(type="tibber")) +# Montag 16.05.2022 23:15 und 23:30: der erste Preis-Eintrag bestimmt den Zeitslot, nicht now() +SLOT_NT = int(datetime.datetime(2022, 5, 16, 23, 15).timestamp()) +SLOT_DAY = int(datetime.datetime(2022, 5, 16, 12, 0).timestamp()) + + +@pytest.mark.parametrize( + "flexible_tariff, grid_fee, first_slot, expected", + [ + pytest.param(NT, None, SLOT_NT, "NT", id="feste Tarifstunden, Tarifzeitraum"), + pytest.param(NT, None, SLOT_DAY, "Standardpreis", id="feste Tarifstunden, Standardpreis"), + pytest.param(None, MODULE_3, SLOT_NT, "Modul 3 NT", id="nur Netzentgelt mit festen Tarifstunden"), + pytest.param(NT, MODULE_3, SLOT_NT, "NT / Modul 3 NT", id="Tarif und Netzentgelt mit festen Tarifstunden"), + pytest.param(DYNAMIC, None, SLOT_NT, None, id="dynamischer Tarif"), + pytest.param(DYNAMIC, MODULE_3, SLOT_NT, None, id="dynamischer Tarif mit festem Netzentgelt"), + ] +) +def test_ep_get_current_tariff_name(flexible_tariff, grid_fee, first_slot, expected): + # setup + opt = Optional() + opt._flexible_tariff_module = flexible_tariff + opt._grid_fee_module = grid_fee + opt.data.electricity_pricing.configured = True + opt.data.electricity_pricing.get.prices = {str(first_slot): 0.0002, str(first_slot + 900): 0.0003} + + # execution + result = opt.ep_get_current_tariff_name() + + # evaluation + assert result == expected + + +@pytest.mark.parametrize( + "configured, prices", + [ + pytest.param(False, {str(SLOT_NT): 0.0002}, id="nicht konfiguriert"), + pytest.param(True, {}, id="keine Preise"), + ] +) +def test_ep_get_current_tariff_name_none(configured, prices): + # setup + opt = Optional() + opt._flexible_tariff_module = NT + opt.data.electricity_pricing.configured = configured + opt.data.electricity_pricing.get.prices = prices + + # execution + result = opt.ep_get_current_tariff_name() + + # evaluation + assert result is None diff --git a/packages/helpermodules/measurement_logging/process_log.py b/packages/helpermodules/measurement_logging/process_log.py index 8df895de7f..d4f835321a 100644 --- a/packages/helpermodules/measurement_logging/process_log.py +++ b/packages/helpermodules/measurement_logging/process_log.py @@ -30,6 +30,7 @@ def get_default_charge_log_columns() -> Dict: "time_time_charged": True, "data_costs": True, "data_power_source": True, + "data_tariff_split": False, "vehicle_name": True, "vehicle_chargemode": True, "vehicle_prio": True, diff --git a/packages/helpermodules/measurement_logging/write_log.py b/packages/helpermodules/measurement_logging/write_log.py index fea8806629..bccbcabe04 100644 --- a/packages/helpermodules/measurement_logging/write_log.py +++ b/packages/helpermodules/measurement_logging/write_log.py @@ -29,6 +29,7 @@ # "grid": Preis für Netzbezug, # "pv": Preis für PV-Strom, # "bat": Preis für Speicherstrom +# "tariff_name": Tarifzeitraum des Netzpreises (Feste Tarifstunden), sonst None # } # "cp": { # "cp1": { @@ -210,14 +211,17 @@ def create_entry(sh_log_data: LegacySmartHomeLogData, previous_entry: Optional[D 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) + tariff_name = data.data.optional_data.ep_get_current_tariff_name() except Exception: grid_price = prices.grid fault_state = 0 + tariff_name = None prices_dict = {"grid": grid_price, "pv": prices.pv, "bat": prices.bat, "cp": prices.cp, - "fault_state": fault_state} + "fault_state": fault_state, + "tariff_name": tariff_name} except Exception: log.exception("Fehler im Werte-Logging-Modul für Preise") prices_dict = {"fault_state": 0} diff --git a/packages/helpermodules/measurement_logging/write_log_test.py b/packages/helpermodules/measurement_logging/write_log_test.py index 1ff6302b4f..09098c2a40 100644 --- a/packages/helpermodules/measurement_logging/write_log_test.py +++ b/packages/helpermodules/measurement_logging/write_log_test.py @@ -1,6 +1,9 @@ from unittest.mock import Mock import json +import pytest + +from control import data from helpermodules.measurement_logging import write_log from helpermodules.measurement_logging.write_log import get_names @@ -161,3 +164,23 @@ def test_save_log_keeps_existing_names_and_colors_when_writing_new_entry(monkeyp "cp1": "#111111", "cp2": "#222222", } + + +@pytest.mark.parametrize("price_available, expected_grid, expected_tariff_name", + [pytest.param(True, 0.00025, "NT", id="Preis und Tarifzeitraum aus dem Preismodul"), + pytest.param(False, 0.0003, None, id="Rückfall auf den Netzpreis ohne Tarifzeitraum")]) +def test_create_entry_prices_tariff_name(price_available, expected_grid, expected_tariff_name, monkeypatch): + # setup + data.data_init(Mock()) + data.data.general_data.data.prices.grid = 0.0003 + optional_data = data.data.optional_data + monkeypatch.setattr(optional_data, "ep_get_current_price", + Mock(return_value=0.00025) if price_available else Mock(side_effect=Exception("keine Preise"))) + monkeypatch.setattr(optional_data, "ep_get_current_tariff_name", Mock(return_value="NT")) + + # execution + entry = write_log.create_entry(Mock(sh_dict={}), None) + + # evaluation + assert entry["prices"]["grid"] == expected_grid + assert entry["prices"]["tariff_name"] == expected_tariff_name diff --git a/packages/helpermodules/update_config.py b/packages/helpermodules/update_config.py index 7b7fb3402d..ae6a634fae 100644 --- a/packages/helpermodules/update_config.py +++ b/packages/helpermodules/update_config.py @@ -63,7 +63,7 @@ class UpdateConfig: - DATASTORE_VERSION = 154 + DATASTORE_VERSION = 155 FILE_OPERATION_VERSION = 0 @@ -4154,3 +4154,13 @@ def upgrade(topic: str, payload) -> Optional[dict]: self._loop_all_received_topics(upgrade) self._append_datastore_version(154) + + def upgrade_datastore_155(self) -> None: + def upgrade(topic: str, payload) -> Optional[dict]: + if "openWB/general/charge_log_data_config" == topic: + config = decode_payload(payload) + if config.get("data_tariff_split") is None: + config["data_tariff_split"] = False + return {topic: config} + self._loop_all_received_topics(upgrade) + self._append_datastore_version(155) diff --git a/packages/helpermodules/update_config_test.py b/packages/helpermodules/update_config_test.py index 9c48ff2735..f2728a7a0a 100644 --- a/packages/helpermodules/update_config_test.py +++ b/packages/helpermodules/update_config_test.py @@ -601,3 +601,21 @@ def test_file_operation_0_start_behavior(file_operation_version, finished, expec 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 + + +@pytest.mark.parametrize("config, expected", + [pytest.param({"data_costs": True}, False, id="neue Spalte standardmäßig ausgeblendet"), + pytest.param({"data_costs": True, "data_tariff_split": True}, True, + id="vorhandene Einstellung bleibt")]) +def test_upgrade_datastore_155_adds_tariff_split_column(config, expected, mock_pub): + uc = UpdateConfig() + uc.all_received_topics = { + "openWB/system/datastore_version": list(range(155)), + "openWB/general/charge_log_data_config": config, + } + + uc.upgrade_datastore_155() + + assert uc.all_received_topics["openWB/general/charge_log_data_config"]["data_tariff_split"] is expected + assert uc.all_received_topics["openWB/general/charge_log_data_config"]["data_costs"] is True + assert 155 in uc.all_received_topics["openWB/system/datastore_version"] diff --git a/packages/modules/electricity_pricing/flexible_tariffs/fixed_hours/tariff.py b/packages/modules/electricity_pricing/flexible_tariffs/fixed_hours/tariff.py index 23649602e5..9f3d6fa2cb 100644 --- a/packages/modules/electricity_pricing/flexible_tariffs/fixed_hours/tariff.py +++ b/packages/modules/electricity_pricing/flexible_tariffs/fixed_hours/tariff.py @@ -2,11 +2,12 @@ import logging import datetime import time -from typing import List, Tuple, Dict +from typing import List, Optional, Tuple, Dict from dataclasses import dataclass from modules.electricity_pricing.flexible_tariffs.fixed_hours.config import (FixedHoursTariff, - FixedHoursTariffConfiguration) + FixedHoursTariffConfiguration, + FixedHoursTariffEntry) from modules.common.abstract_device import DeviceDescriptor from modules.common.component_state import TariffState @@ -66,22 +67,40 @@ def _fetch(config: FixedHoursTariffConfiguration) -> TariffState: return _calculate_price_timeslots(config) -def _fetch_price_for_time_slot(config: FixedHoursTariffConfiguration, +def _find_tariff_for_time_slot(config: FixedHoursTariffConfiguration, time_slot: datetime.datetime - ) -> float: + ) -> Optional[FixedHoursTariffEntry]: for tariff in config.tariffs: active_times = [(_to_time(start), _to_time(end)) for start, end in tariff["active_times"]["times"]] active_dates = [ (_to_date(start, time_slot), _to_date(end, time_slot)) - for start, end in tariff["active_times"]["dates"] + for start, end in tariff["active_times"].get("dates") or [] ] # If no weekdays specified, assume all days are active - weekdays = tariff["active_times"]["weekdays"] or range(7) + # (get: upgrade_datastore_133 hat weekdays neben active_times statt darin angelegt) + weekdays = tariff["active_times"].get("weekdays") or range(7) if (any(start <= time_slot.time() < end for start, end in active_times) and any(start <= time_slot.date() <= end for start, end in active_dates) and time_slot.weekday() in weekdays): - return tariff["price"] - return config.default_price + return tariff + return None + + +def _fetch_price_for_time_slot(config: FixedHoursTariffConfiguration, + time_slot: datetime.datetime + ) -> float: + tariff = _find_tariff_for_time_slot(config, time_slot) + return tariff["price"] if tariff is not None else config.default_price + + +def get_tariff_name_for_time_slot(config: FixedHoursTariffConfiguration, + time_slot: datetime.datetime, + default_name: Optional[str] = None + ) -> Optional[str]: + """Name des Tarifzeitraums, dessen Preis _fetch_price_for_time_slot für diesen Zeitslot liefert; + default_name, wenn der Standardpreis gilt.""" + tariff = _find_tariff_for_time_slot(config, time_slot) + return tariff["name"] if tariff is not None else default_name def _calculate_price_timeslots(config: FixedHoursTariffConfiguration) -> TariffState: diff --git a/packages/modules/electricity_pricing/flexible_tariffs/fixed_hours/tariff_test.py b/packages/modules/electricity_pricing/flexible_tariffs/fixed_hours/tariff_test.py index 2febc8a30e..1cbfd1c27d 100644 --- a/packages/modules/electricity_pricing/flexible_tariffs/fixed_hours/tariff_test.py +++ b/packages/modules/electricity_pricing/flexible_tariffs/fixed_hours/tariff_test.py @@ -8,7 +8,9 @@ _to_date, _validate_tariff_times, _fetch, + _fetch_price_for_time_slot, create_electricity_tariff, + get_tariff_name_for_time_slot, ) from modules.electricity_pricing.flexible_tariffs.fixed_hours.config import ( FixedHoursTariff, @@ -391,3 +393,70 @@ def test_create_electricity_tariff_updater( assert len(result.prices) > 0 for price in result.prices.values(): assert price == expected_price + + +class TestGetTariffNameForTimeSlot: + """Test get_tariff_name_for_time_slot function""" + + # HT/NT wie in den Niederlanden: NT werktags 23-7 Uhr und am Wochenende, sonst Standardpreis + TARIFFS = [ + { + "name": "NT", + "price": 0.00025, + "active_times": { + "dates": [("01-01", "31-12")], + "times": [("00:00", "07:00"), ("23:00", "24:00")], + "weekdays": [0, 1, 2, 3, 4], + }, + }, + { + "name": "NT Wochenende", + "price": 0.00025, + "active_times": { + "dates": [("01-01", "31-12")], + "times": [("00:00", "24:00")], + "weekdays": [5, 6], + }, + }, + ] + + @pytest.mark.parametrize( + "time_slot,default_name,expected_name", + [ + # Montag 16.05.2022 + pytest.param(datetime.datetime(2022, 5, 16, 6, 45), None, "NT", id="weekday_morning_nt"), + pytest.param(datetime.datetime(2022, 5, 16, 7, 0), None, None, id="weekday_day_default_none"), + pytest.param(datetime.datetime(2022, 5, 16, 12, 0), "Standardpreis", "Standardpreis", + id="weekday_day_default_name"), + pytest.param(datetime.datetime(2022, 5, 16, 23, 15), None, "NT", id="weekday_evening_nt"), + pytest.param(datetime.datetime(2022, 5, 21, 12, 0), None, "NT Wochenende", id="saturday"), + ], + ) + def test_name_for_time_slot(self, time_slot, default_name, expected_name): + config = FixedHoursTariffConfiguration(default_price=0.0003, tariffs=self.TARIFFS) + + assert get_tariff_name_for_time_slot(config, time_slot, default_name) == expected_name + + def test_name_and_price_use_the_same_matching(self): + """Name und Preis müssen für jeden Zeitslot zum selben Tarif gehören.""" + tariffs = [dict(tariff, price=price) for tariff, price in zip(self.TARIFFS, (0.0001, 0.0002))] + config = FixedHoursTariffConfiguration(default_price=0.0003, tariffs=tariffs) + prices_by_name = {"NT": 0.0001, "NT Wochenende": 0.0002, None: 0.0003} + start = datetime.datetime(2022, 5, 16, 0, 0) + for quarter_hour in range(7 * 24 * 4): + time_slot = start + datetime.timedelta(minutes=15 * quarter_hour) + name = get_tariff_name_for_time_slot(config, time_slot) + assert prices_by_name[name] == _fetch_price_for_time_slot(config, time_slot), time_slot + + def test_missing_weekdays_and_dates(self): + """Fehlende weekdays gelten wie leere für alle Tage, fehlende dates wie leere für keinen Tag.""" + no_weekdays = {"name": "ohne Wochentage", "price": 0.0001, + "active_times": {"dates": [("01-01", "31-12")], "times": [("00:00", "24:00")]}} + no_dates = {"name": "ohne Datum", "price": 0.0001, + "active_times": {"times": [("00:00", "24:00")], "weekdays": [0, 1, 2, 3, 4, 5, 6]}} + time_slot = datetime.datetime(2022, 5, 21, 12, 0) + + assert get_tariff_name_for_time_slot( + FixedHoursTariffConfiguration(tariffs=[no_weekdays]), time_slot) == "ohne Wochentage" + assert get_tariff_name_for_time_slot( + FixedHoursTariffConfiguration(tariffs=[no_dates]), time_slot, "Standardpreis") == "Standardpreis" diff --git a/web/settings/downloadChargeLog.php b/web/settings/downloadChargeLog.php index 8eea3d96a0..f749772ca8 100644 --- a/web/settings/downloadChargeLog.php +++ b/web/settings/downloadChargeLog.php @@ -163,13 +163,36 @@ function formatEnergy($value) return number_format($value / 1000, 2, ",", ""); } +# Tarifzeiträume (Feste Tarifstunden) in der Reihenfolge ihres ersten Auftretens +function getTariffNames($charge_log_data) +{ + $tariff_names = []; + foreach ($charge_log_data as $log_entry) { + if (isset($log_entry["data"]["tariff_split"]) && is_array($log_entry["data"]["tariff_split"])) { + foreach (array_keys($log_entry["data"]["tariff_split"]) as $tariff_name) { + if (!in_array($tariff_name, $tariff_names, true)) { + $tariff_names[] = $tariff_name; + } + } + } + } + return $tariff_names; +} + if (is_array($charge_log_data)) { header("Content-Type: text/csv"); header("Content-Disposition: attachment; filename=ChargeLog-" . $output_file_name . ".csv"); + # Tarifaufteilung: je Tarifzeitraum zwei Spalten am Ende, nur wenn Einträge sie enthalten + $tariff_names = getTariffNames($charge_log_data); + # Output CSV header $csv_header = newRow(); $translated_header = array_map("translateHeading", array_keys($csv_header)); + foreach ($tariff_names as $tariff_name) { + $translated_header[] = "Netzbezug " . str_replace('"', '""', $tariff_name); + $translated_header[] = "Kosten Netzbezug " . str_replace('"', '""', $tariff_name); + } print('"' . implode('";"', $translated_header) . "\"\n"); # Output CSV data @@ -223,6 +246,11 @@ function formatEnergy($value) break; } } + foreach ($tariff_names as $tariff_name) { + $tariff = $log_entry["data"]["tariff_split"][$tariff_name] ?? null; + $csv_row[] = isset($tariff["energy"]) ? formatEnergy($tariff["energy"]) : ""; + $csv_row[] = isset($tariff["costs"]) ? formatMoney($tariff["costs"]) : ""; + } print(implode(";", $csv_row) . "\n"); } } From 59b32623f208ecce485cee0d19c67bba8c547809 Mon Sep 17 00:00:00 2001 From: buhito81 <101875447+buhito81@users.noreply.github.com> Date: Thu, 15 Oct 2026 17:35:11 +0200 Subject: [PATCH 2/3] Ladeprotokoll: geladene Energie und Kosten je Tarifzeitraum statt nur Netzanteil MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Die Aufteilung je Tarifzeitraum enthält jetzt die gesamte geladene Energie des Intervalls (über alle Quellen) und seine Kosten, wie sie in costs eingehen. Die Kosten je Tarifzeitraum ergeben so in Summe data.costs. CSV-Spalten heißen "Energie " / "Kosten ". Co-Authored-By: Claude Opus 5.5 --- packages/control/chargelog/chargelog.py | 22 ++++++++++--------- packages/control/chargelog/chargelog_test.py | 21 +++++++++++++++--- .../control/chargelog/process_chargelog.py | 4 ++-- .../control/chargepoint/chargepoint_data.py | 2 +- web/settings/downloadChargeLog.php | 4 ++-- 5 files changed, 35 insertions(+), 18 deletions(-) diff --git a/packages/control/chargelog/chargelog.py b/packages/control/chargelog/chargelog.py index 405939e663..808f78d9f0 100644 --- a/packages/control/chargelog/chargelog.py +++ b/packages/control/chargelog/chargelog.py @@ -55,7 +55,7 @@ # "pv": 0.25, # "bat": 0.5, # "cp": 0}, -# "tariff_split": { # optional, nur bei Festen Tarifstunden: Netzanteil je Tarifzeitraum +# "tariff_split": { # optional, nur bei Festen Tarifstunden: geladene Energie und Kosten je Tarifzeitraum # "NT": {"energy": 1000, "costs": 0.25}} # } # } @@ -346,22 +346,24 @@ def calc_energy_costs(cp, create_log_entry: bool = False): else: costs = _calc_costs(charged_energy_by_source, reference_entries[-1]["prices"]) cp.data.set.log.costs += costs - _add_tariff_split(cp.data.set.log, charged_energy_by_source, reference_entries[-1]["prices"]) + _add_tariff_split(cp.data.set.log, charged_energy_by_source, costs, + reference_entries[-1]["prices"].get("tariff_name")) except Exception: log.exception(f"Fehler beim Berechnen der Ladekosten für Ladepunkt {cp.num}") -def _add_tariff_split(log_data, charged_energy_by_source: Dict[str, float], prices: Dict) -> None: - """ Netzanteil des Intervalls dem Tarifzeitraum (Feste Tarifstunden) zuordnen, mit demselben Preis wie - _calc_costs. Ohne Tarifname (dynamische Tarife, kein Preismodul) bleibt es bei der Summe in costs. +def _add_tariff_split(log_data, charged_energy_by_source: Dict[str, float], costs: float, + tariff_name: Optional[str]) -> None: + """ Geladene Energie des Intervalls (über alle Quellen) und seine Kosten, wie sie in costs eingehen, dem + Tarifzeitraum (Feste Tarifstunden) zuordnen. Die Kosten je Tarifzeitraum ergeben so in Summe costs. + Ohne Tarifname (dynamische Tarife, kein Preismodul) bleibt es bei der Summe in costs. """ - tariff_name = prices.get("tariff_name") - grid_energy = charged_energy_by_source["grid"] - if tariff_name is None or grid_energy <= 0: + energy = sum(charged_energy_by_source.values()) + if tariff_name is None or energy <= 0: return tariff = log_data.tariff_split.setdefault(tariff_name, {"energy": 0, "costs": 0}) - tariff["energy"] += grid_energy - tariff["costs"] += grid_energy * prices["grid"] + tariff["energy"] += energy + tariff["costs"] += costs def _get_tariff_split(log_data) -> Dict[str, Dict[str, float]]: diff --git a/packages/control/chargelog/chargelog_test.py b/packages/control/chargelog/chargelog_test.py index 3b25c0b553..49827345e8 100644 --- a/packages/control/chargelog/chargelog_test.py +++ b/packages/control/chargelog/chargelog_test.py @@ -8,6 +8,7 @@ from control.chargelog import chargelog from control.chargelog.chargelog import calc_energy_costs from control.chargepoint.chargepoint import Chargepoint +from control.chargepoint.chargepoint_data import Log from control.general import Prices @@ -186,13 +187,27 @@ def test_calc_charge_cost_tariff_split(mock_data, monkeypatch): with patch("builtins.open", mock_open(read_data=json.dumps(daily_log))): calc_energy_costs(cp) - # nur der Netzanteil dieses Intervalls (1242.8 - 100 Wh) mit dem Netzpreis dieses Intervalls - assert round(cp.data.set.log.tariff_split["NT"]["energy"], 2) == 2142.8 - assert round(cp.data.set.log.tariff_split["NT"]["costs"], 5) == round(0.25 + 1142.8 * 0.0003, 5) + # die gesamte geladene Energie dieses Intervalls (4000 - 2000 Wh, über alle Quellen) und seine Kosten + assert round(cp.data.set.log.tariff_split["NT"]["energy"], 2) == 3000 + assert round(cp.data.set.log.tariff_split["NT"]["costs"], 5) == 0.75 assert cp.data.set.log.tariff_split["Standardpreis"] == {"energy": 100, "costs": 0.03} assert round(cp.data.set.log.costs, 5) == 0.5 +def test_add_tariff_split_counts_all_sources(): + log_data = Log() + prices = {'grid': 0.0003, 'pv': 0.0001, 'bat': 0.0002, 'cp': 0, 'tariff_name': "NT"} + charged_energy_by_source = {'grid': 600, 'pv': 400, 'bat': 0, 'cp': 0} + costs = chargelog._calc_costs(charged_energy_by_source, prices) + + chargelog._add_tariff_split(log_data, charged_energy_by_source, costs, prices["tariff_name"]) + chargelog._add_tariff_split(log_data, {'grid': 0, 'pv': 0, 'bat': 0, 'cp': 0}, 0, "HT") + + # Energie = Zähler des Ladepunkts (PV + Netz), Kosten = Ergebnis von _calc_costs; leere Intervalle zählen nicht + assert log_data.tariff_split == {"NT": {"energy": 1000, "costs": costs}} + assert costs == round(600 * 0.0003 + 400 * 0.0001, 4) + + @pytest.mark.parametrize("tariff_name", [pytest.param(None, id="dynamischer Tarif"), pytest.param("missing", id="alter Tageslog-Eintrag")]) def test_calc_charge_cost_without_tariff_name(tariff_name, mock_data, monkeypatch): diff --git a/packages/control/chargelog/process_chargelog.py b/packages/control/chargelog/process_chargelog.py index a0a24b0239..7e15a4abb9 100644 --- a/packages/control/chargelog/process_chargelog.py +++ b/packages/control/chargelog/process_chargelog.py @@ -126,8 +126,8 @@ def get_sum(entry_name: str) -> float: def get_tariff_split_sum(entries: List[Dict]) -> Dict[str, Dict[str, float]]: - """ Netzanteil je Tarifzeitraum summieren. Einträge ohne Aufteilung (ältere Einträge, dynamische Tarife) werden - übersprungen, data.costs bleibt die maßgebliche Summe. + """ Geladene Energie und Kosten je Tarifzeitraum summieren. Einträge ohne Aufteilung (ältere Einträge, + dynamische Tarife) werden übersprungen, data.costs bleibt die maßgebliche Summe. """ tariff_split_sum = {} for entry in entries: diff --git a/packages/control/chargepoint/chargepoint_data.py b/packages/control/chargepoint/chargepoint_data.py index 60b973824a..6e1bab0f0c 100644 --- a/packages/control/chargepoint/chargepoint_data.py +++ b/packages/control/chargepoint/chargepoint_data.py @@ -98,7 +98,7 @@ class Log: range_at_start: Optional[float] = None range_at_end: Optional[float] = None odometer: Optional[float] = None - # Netzanteil je Tarifzeitraum (Feste Tarifstunden): {Name: {"energy": Wh, "costs": €}} + # geladene Energie und Kosten je Tarifzeitraum (Feste Tarifstunden): {Name: {"energy": Wh, "costs": €}} tariff_split: Dict[str, Dict[str, float]] = field(default_factory=empty_dict_factory) diff --git a/web/settings/downloadChargeLog.php b/web/settings/downloadChargeLog.php index f749772ca8..63f2204ea0 100644 --- a/web/settings/downloadChargeLog.php +++ b/web/settings/downloadChargeLog.php @@ -190,8 +190,8 @@ function getTariffNames($charge_log_data) $csv_header = newRow(); $translated_header = array_map("translateHeading", array_keys($csv_header)); foreach ($tariff_names as $tariff_name) { - $translated_header[] = "Netzbezug " . str_replace('"', '""', $tariff_name); - $translated_header[] = "Kosten Netzbezug " . str_replace('"', '""', $tariff_name); + $translated_header[] = "Energie " . str_replace('"', '""', $tariff_name); + $translated_header[] = "Kosten " . str_replace('"', '""', $tariff_name); } print('"' . implode('";"', $translated_header) . "\"\n"); From a224efa4411c31c9ea8d28ddf918118cbf0c5ea1 Mon Sep 17 00:00:00 2001 From: buhito81 <101875447+buhito81@users.noreply.github.com> Date: Fri, 16 Oct 2026 12:19:27 +0200 Subject: [PATCH 3/3] Tageslog: tariff_name nur bei Festen Tarifstunden schreiben MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ohne Tarifzeitraum (dynamische Tarife, kein Preismodul, Rückfallpfad) bleibt der Preis-Eintrag im Tageslog unverändert. Co-Authored-By: Claude Opus 5.5 --- .../measurement_logging/write_log.py | 7 ++++--- .../measurement_logging/write_log_test.py | 17 +++++++++++------ 2 files changed, 15 insertions(+), 9 deletions(-) diff --git a/packages/helpermodules/measurement_logging/write_log.py b/packages/helpermodules/measurement_logging/write_log.py index bccbcabe04..5433ff1337 100644 --- a/packages/helpermodules/measurement_logging/write_log.py +++ b/packages/helpermodules/measurement_logging/write_log.py @@ -29,7 +29,7 @@ # "grid": Preis für Netzbezug, # "pv": Preis für PV-Strom, # "bat": Preis für Speicherstrom -# "tariff_name": Tarifzeitraum des Netzpreises (Feste Tarifstunden), sonst None +# "tariff_name": Tarifzeitraum des Netzpreises, nur bei Festen Tarifstunden # } # "cp": { # "cp1": { @@ -220,8 +220,9 @@ def create_entry(sh_log_data: LegacySmartHomeLogData, previous_entry: Optional[D "pv": prices.pv, "bat": prices.bat, "cp": prices.cp, - "fault_state": fault_state, - "tariff_name": tariff_name} + "fault_state": fault_state} + if tariff_name is not None: + prices_dict["tariff_name"] = tariff_name except Exception: log.exception("Fehler im Werte-Logging-Modul für Preise") prices_dict = {"fault_state": 0} diff --git a/packages/helpermodules/measurement_logging/write_log_test.py b/packages/helpermodules/measurement_logging/write_log_test.py index 09098c2a40..38609ee496 100644 --- a/packages/helpermodules/measurement_logging/write_log_test.py +++ b/packages/helpermodules/measurement_logging/write_log_test.py @@ -166,21 +166,26 @@ def test_save_log_keeps_existing_names_and_colors_when_writing_new_entry(monkeyp } -@pytest.mark.parametrize("price_available, expected_grid, expected_tariff_name", - [pytest.param(True, 0.00025, "NT", id="Preis und Tarifzeitraum aus dem Preismodul"), - pytest.param(False, 0.0003, None, id="Rückfall auf den Netzpreis ohne Tarifzeitraum")]) -def test_create_entry_prices_tariff_name(price_available, expected_grid, expected_tariff_name, monkeypatch): +@pytest.mark.parametrize("price_available, tariff_name, expected_grid, expected_tariff_name", + [pytest.param(True, "NT", 0.00025, "NT", id="Preis und Tarifzeitraum aus dem Preismodul"), + pytest.param(True, None, 0.00025, None, id="dynamischer Tarif: kein Feld im Tageslog"), + pytest.param(False, "NT", 0.0003, None, id="Rückfall auf den Netzpreis: kein Feld")]) +def test_create_entry_prices_tariff_name(price_available, tariff_name, expected_grid, expected_tariff_name, + monkeypatch): # setup data.data_init(Mock()) data.data.general_data.data.prices.grid = 0.0003 optional_data = data.data.optional_data monkeypatch.setattr(optional_data, "ep_get_current_price", Mock(return_value=0.00025) if price_available else Mock(side_effect=Exception("keine Preise"))) - monkeypatch.setattr(optional_data, "ep_get_current_tariff_name", Mock(return_value="NT")) + monkeypatch.setattr(optional_data, "ep_get_current_tariff_name", Mock(return_value=tariff_name)) # execution entry = write_log.create_entry(Mock(sh_dict={}), None) # evaluation assert entry["prices"]["grid"] == expected_grid - assert entry["prices"]["tariff_name"] == expected_tariff_name + if expected_tariff_name is None: + assert "tariff_name" not in entry["prices"] + else: + assert entry["prices"]["tariff_name"] == expected_tariff_name