diff --git a/packages/control/chargelog/chargelog.py b/packages/control/chargelog/chargelog.py index 674159cdc9..2a6a27ffc1 100644 --- a/packages/control/chargelog/chargelog.py +++ b/packages/control/chargelog/chargelog.py @@ -56,7 +56,9 @@ # "grid": 0.25, # "pv": 0.25, # "bat": 0.5, -# "cp": 0} +# "cp": 0}, +# "tariff_split": { # optional, nur bei Festen Tarifstunden: geladene Energie und Kosten je Tarifzeitraum +# "NT": {"energy": 1000, "costs": 0.25}} # } # } log = logging.getLogger("chargelog") @@ -300,6 +302,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 @@ -343,10 +348,31 @@ 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, 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], 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. + """ + 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"] += energy + tariff["costs"] += costs + + +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 e28d11623f..fceedcaa97 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 from helpermodules.measurement_logging import process_log_entry_builder @@ -277,6 +278,114 @@ def test_calc_charge_cost_reference_middle_day_change(mock_data, monkeypatch): 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) + + # 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_calc_charge_cost_tariff_split_end_uses_current_entry(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 = 4100 + cp.data.set.log.charged_energy_by_source = {'grid': 1243, 'pv': 386, 'bat': 671, 'cp': 0.0} + daily_log = mock_daily_log(monkeypatch) + daily_log["entries"][-1]["prices"]["tariff_name"] = "NT" + current_entry = json.loads(json.dumps(daily_log["entries"][-1])) + current_entry["bat"]["all"]["exported"] = 4000 + current_entry["bat"]["bat2"]["exported"] = 4000 + current_entry["counter"]["counter0"]["imported"] = 4500 + current_entry["cp"]["cp4"]["imported"] = 4100 + current_entry["pv"]["all"]["exported"] = 3000 + current_entry["pv"]["pv1"]["exported"] = 3000 + current_entry["prices"]["tariff_name"] = "HT" + monkeypatch.setattr(chargelog, "create_entry", Mock(return_value=current_entry)) + + with patch("builtins.open", mock_open(read_data=json.dumps(daily_log))): + calc_energy_costs(cp, True) + + # das angebrochene letzte Intervall gehört zum Tarifzeitraum der aktuellen Messung + assert {name: {key: round(value, 5) for key, value in tariff.items()} + for name, tariff in cp.data.set.log.tariff_split.items()} == {"HT": {"energy": 100.0, "costs": 0.025}} + + +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): + 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 == {} + + def test_small_positive_charge_is_assigned_to_energy_sources(mock_data, monkeypatch): cp = Chargepoint(4, None) charged_energy = 80 diff --git a/packages/control/chargelog/process_chargelog.py b/packages/control/chargelog/process_chargelog.py index 9349db3e26..7e15a4abb9 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]]: + """ 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: + 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 2b17b6e853..67b932b03e 100644 --- a/packages/control/chargepoint/chargepoint_data.py +++ b/packages/control/chargepoint/chargepoint_data.py @@ -99,6 +99,8 @@ class Log: range_at_start: Optional[float] = None range_at_end: Optional[float] = None odometer: Optional[float] = None + # 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) def connected_vehicle_factory() -> ConnectedVehicle: diff --git a/packages/control/optional.py b/packages/control/optional.py index e33d9544b5..ea27cece6b 100644 --- a/packages/control/optional.py +++ b/packages/control/optional.py @@ -18,10 +18,12 @@ from modules.common.configurable_forecast import ConfigurableForecast 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): @@ -220,6 +222,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 4ba580664a..cd00926c02 100644 --- a/packages/control/optional_test.py +++ b/packages/control/optional_test.py @@ -1,8 +1,11 @@ +import datetime from dataclasses import dataclass 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) @dataclass @@ -499,6 +502,68 @@ def test_et_price_update_required(monkeypatch, flexible_tariff, grid_fee, curren 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 + + class TestForecastModule: def _configured_opt(self) -> Optional: opt = Optional() 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/process_log_entry_builder.py b/packages/helpermodules/measurement_logging/process_log_entry_builder.py index bcb653a631..2a57d54084 100644 --- a/packages/helpermodules/measurement_logging/process_log_entry_builder.py +++ b/packages/helpermodules/measurement_logging/process_log_entry_builder.py @@ -54,14 +54,18 @@ def create_entry(sh_log_data: Optional[LegacySmartHomeLogData], previous_entry: 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} + 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.py b/packages/helpermodules/measurement_logging/write_log.py index e4397da48c..95d523ad74 100644 --- a/packages/helpermodules/measurement_logging/write_log.py +++ b/packages/helpermodules/measurement_logging/write_log.py @@ -26,6 +26,7 @@ # "grid": Preis für Netzbezug, # "pv": Preis für PV-Strom, # "bat": Preis für Speicherstrom +# "tariff_name": Tarifzeitraum des Netzpreises, nur bei Festen Tarifstunden # } # "cp": { # "cp1": { diff --git a/packages/helpermodules/measurement_logging/write_log_test.py b/packages/helpermodules/measurement_logging/write_log_test.py index 2616d62108..1e8b9b2259 100644 --- a/packages/helpermodules/measurement_logging/write_log_test.py +++ b/packages/helpermodules/measurement_logging/write_log_test.py @@ -1,11 +1,14 @@ 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 -from helpermodules.measurement_logging.process_log_entry_builder import fix_values +from helpermodules.measurement_logging.process_log_entry_builder import create_entry, fix_values def test_get_names(daily_log_totals, monkeypatch): @@ -167,3 +170,28 @@ def test_save_log_keeps_existing_names_and_colors_when_writing_new_entry(monkeyp "cp1": "#111111", "cp2": "#222222", } + + +@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=tariff_name)) + + # execution + entry = create_entry(Mock(sh_dict={}), None) + + # evaluation + assert entry["prices"]["grid"] == expected_grid + if expected_tariff_name is None: + assert "tariff_name" not in entry["prices"] + else: + assert entry["prices"]["tariff_name"] == expected_tariff_name diff --git a/packages/helpermodules/update_config.py b/packages/helpermodules/update_config.py index 3394623b07..40b9c39680 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 @@ -4173,3 +4173,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..63f2204ea0 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[] = "Energie " . str_replace('"', '""', $tariff_name); + $translated_header[] = "Kosten " . 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"); } }