From c3b8934ab159d594f1ef819b787a19778e01ceee Mon Sep 17 00:00:00 2001 From: seaspotter Date: Thu, 5 Nov 2026 21:07:15 +0100 Subject: [PATCH 1/3] Persist PV forecast values into the daily log --- .../measurement_logging/write_log.py | 15 +++++++++++++++ .../measurement_logging/write_log_test.py | 6 ++++++ 2 files changed, 21 insertions(+) diff --git a/packages/helpermodules/measurement_logging/write_log.py b/packages/helpermodules/measurement_logging/write_log.py index e4397da48c..c013051276 100644 --- a/packages/helpermodules/measurement_logging/write_log.py +++ b/packages/helpermodules/measurement_logging/write_log.py @@ -101,6 +101,8 @@ # ], # "names": {"cp1": "", "counter2": "", "pv3": ""}, # "colors": {"cp1": "", "counter2": "", "pv3": ""}, +# "forecast": {"": W, ...}, # PV-Prognose für den jeweiligen Tag, wächst/verfeinert +# # sich mit jedem Logging-Zyklus # } @@ -142,6 +144,11 @@ def save_log(): new_colors = get_colors(content["entries"][-1]) existing_colors.update(new_colors) content["colors"] = existing_colors + existing_forecast = content.get("forecast") + if not isinstance(existing_forecast, dict): + existing_forecast = {} + existing_forecast.update(get_forecast_values()) + content["forecast"] = existing_forecast write_and_check(filepath, content) return content["entries"] except Exception: @@ -149,6 +156,14 @@ def save_log(): return None +def get_forecast_values() -> Dict: + try: + return data.data.optional_data.data.forecast.get.today_values or {} + except Exception: + log.exception("Fehler beim Speichern der Prognosewerte") + return {} + + def get_previous_entry(parent_file: Path, content: Dict) -> Optional[Dict]: try: previous_entry = content["entries"][-1] diff --git a/packages/helpermodules/measurement_logging/write_log_test.py b/packages/helpermodules/measurement_logging/write_log_test.py index 2616d62108..45cf9dd5b4 100644 --- a/packages/helpermodules/measurement_logging/write_log_test.py +++ b/packages/helpermodules/measurement_logging/write_log_test.py @@ -133,6 +133,7 @@ def test_save_log_keeps_existing_names_and_colors_when_writing_new_entry(monkeyp "entries": [{"timestamp": 1, "cp": {"cp1": {"imported": 1, "exported": 0}}}], "names": {"cp1": "Bestehender Ladepunkt"}, "colors": {"cp1": "#111111"}, + "forecast": {"1": 100.0}, } mocked_file = Mock() mocked_file.__enter__ = Mock(return_value=mocked_file) @@ -148,6 +149,7 @@ def test_save_log_keeps_existing_names_and_colors_when_writing_new_entry(monkeyp monkeypatch.setattr(write_log, "get_names", Mock( return_value={"cp1": "Bestehender Ladepunkt", "cp2": "Neuer Ladepunkt"})) monkeypatch.setattr(write_log, "get_colors", Mock(return_value={"cp1": "#111111", "cp2": "#222222"})) + monkeypatch.setattr(write_log, "get_forecast_values", Mock(return_value={"2": 200.0})) write_and_check_mock = Mock() monkeypatch.setattr(write_log, "write_and_check", write_and_check_mock) @@ -167,3 +169,7 @@ def test_save_log_keeps_existing_names_and_colors_when_writing_new_entry(monkeyp "cp1": "#111111", "cp2": "#222222", } + assert written_content["forecast"] == { + "1": 100.0, + "2": 200.0, + } From 81d2a1e0d6a9d2075c001a51e5bfa27b40431334 Mon Sep 17 00:00:00 2001 From: seaspotter Date: Fri, 6 Nov 2026 13:16:57 +0100 Subject: [PATCH 2/3] Guard against non-dict forecast values aborting the daily log write --- .../measurement_logging/write_log.py | 4 +- .../measurement_logging/write_log_test.py | 40 +++++++++++++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/packages/helpermodules/measurement_logging/write_log.py b/packages/helpermodules/measurement_logging/write_log.py index c013051276..95adc53941 100644 --- a/packages/helpermodules/measurement_logging/write_log.py +++ b/packages/helpermodules/measurement_logging/write_log.py @@ -147,7 +147,9 @@ def save_log(): existing_forecast = content.get("forecast") if not isinstance(existing_forecast, dict): existing_forecast = {} - existing_forecast.update(get_forecast_values()) + new_forecast = get_forecast_values() + if isinstance(new_forecast, dict): + existing_forecast.update(new_forecast) content["forecast"] = existing_forecast write_and_check(filepath, content) return content["entries"] diff --git a/packages/helpermodules/measurement_logging/write_log_test.py b/packages/helpermodules/measurement_logging/write_log_test.py index 45cf9dd5b4..915f72ae84 100644 --- a/packages/helpermodules/measurement_logging/write_log_test.py +++ b/packages/helpermodules/measurement_logging/write_log_test.py @@ -173,3 +173,43 @@ def test_save_log_keeps_existing_names_and_colors_when_writing_new_entry(monkeyp "1": 100.0, "2": 200.0, } + + +def test_get_forecast_values_reads_real_data_path(monkeypatch): + monkeypatch.setattr( + write_log.data, "data", + Mock(optional_data=Mock(data=Mock(forecast=Mock(get=Mock(today_values={"123": 456.0})))))) + + assert write_log.get_forecast_values() == {"123": 456.0} + + +def test_get_forecast_values_falls_back_to_empty_dict_on_error(monkeypatch): + # eg. data.data not fully initialized yet - None has no .optional_data, raising AttributeError + monkeypatch.setattr(write_log.data, "data", None) + + assert write_log.get_forecast_values() == {} + + +def test_save_log_ignores_non_dict_forecast_values(monkeypatch): + # a malformed/corrupted forecast value must not abort the whole daily-log write - matches + # the existing defensive handling for "names"/"colors" in the same function + existing_content = {"entries": [{"timestamp": 1}], "names": {}, "colors": {}, "forecast": {"1": 100.0}} + mocked_file = Mock() + mocked_file.__enter__ = Mock(return_value=mocked_file) + mocked_file.__exit__ = Mock(return_value=None) + mocked_file.read = Mock(return_value=json.dumps(existing_content)) + monkeypatch.setattr("builtins.open", Mock(return_value=mocked_file)) + monkeypatch.setattr(write_log.timecheck, "create_timestamp_YYYYMMDD", Mock(return_value="20261002")) + monkeypatch.setattr(write_log, "get_previous_entry", Mock(return_value=existing_content["entries"][-1])) + monkeypatch.setattr(write_log, "LegacySmartHomeLogData", Mock(return_value=Mock(sh_names={}, sh_dict={}))) + monkeypatch.setattr(write_log, "create_entry", Mock(return_value={"timestamp": 2})) + monkeypatch.setattr(write_log, "get_names", Mock(return_value={})) + monkeypatch.setattr(write_log, "get_colors", Mock(return_value={})) + monkeypatch.setattr(write_log, "get_forecast_values", Mock(return_value=["not", "a", "dict"])) + write_and_check_mock = Mock() + monkeypatch.setattr(write_log, "write_and_check", write_and_check_mock) + + write_log.save_log() + + written_content = write_and_check_mock.call_args.args[1] + assert written_content["forecast"] == {"1": 100.0} From f1ae2370fb133e8959beb2ee60c1d7d8d35745f3 Mon Sep 17 00:00:00 2001 From: seaspotter Date: Wed, 11 Nov 2026 11:05:29 +0100 Subject: [PATCH 3/3] Log only the current PV forecast value per entry, not the whole growing day curve --- packages/control/optional.py | 14 ++++++ .../process_log_entry_builder.py | 7 +++ .../measurement_logging/write_log.py | 17 ------- .../measurement_logging/write_log_test.py | 46 ------------------- 4 files changed, 21 insertions(+), 63 deletions(-) diff --git a/packages/control/optional.py b/packages/control/optional.py index e33d9544b5..30b376119c 100644 --- a/packages/control/optional.py +++ b/packages/control/optional.py @@ -220,6 +220,20 @@ def ep_get_current_price(self) -> float: else: raise Exception("Kein Anbieter für strompreisbasiertes Laden konfiguriert.") + def forecast_get_current_value(self) -> TypingOptional[float]: + """Liefert den aktuellen Prognosewert. Anders als bei den Strompreisen enthält + today_values auch bereits vergangene Stunden des Tages, es wird also hier (statt schon + beim Speichern) nach dem letzten Zeitstempel <= jetzt gefiltert.""" + today_values = self.data.forecast.get.today_values + if not today_values: + return None + now = timecheck.create_timestamp() + past_or_current = [timestamp for timestamp in today_values if float(timestamp) <= now] + if not past_or_current: + return None + closest_timestamp = max(past_or_current, key=float) + return today_values[closest_timestamp] + 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/helpermodules/measurement_logging/process_log_entry_builder.py b/packages/helpermodules/measurement_logging/process_log_entry_builder.py index bcb653a631..8631ed6e36 100644 --- a/packages/helpermodules/measurement_logging/process_log_entry_builder.py +++ b/packages/helpermodules/measurement_logging/process_log_entry_builder.py @@ -66,6 +66,12 @@ def create_entry(sh_log_data: Optional[LegacySmartHomeLogData], previous_entry: log.exception("Fehler im Werte-Logging-Modul für Preise") prices_dict = {"fault_state": 0} + try: + forecast_value = data.data.optional_data.forecast_get_current_value() + except Exception: + log.exception("Fehler im Werte-Logging-Modul für die PV-Prognose") + forecast_value = None + try: cp_dict = {"all": {"imported": data.data.cp_all_data.data.get.imported, "exported": data.data.cp_all_data.data.get.exported, @@ -190,6 +196,7 @@ def create_entry(sh_log_data: Optional[LegacySmartHomeLogData], previous_entry: "timestamp": current_timestamp, "date": date, "prices": prices_dict, + "forecast": forecast_value, "cp": cp_dict, "ev": ev_dict, "counter": counter_dict, diff --git a/packages/helpermodules/measurement_logging/write_log.py b/packages/helpermodules/measurement_logging/write_log.py index 95adc53941..e4397da48c 100644 --- a/packages/helpermodules/measurement_logging/write_log.py +++ b/packages/helpermodules/measurement_logging/write_log.py @@ -101,8 +101,6 @@ # ], # "names": {"cp1": "", "counter2": "", "pv3": ""}, # "colors": {"cp1": "", "counter2": "", "pv3": ""}, -# "forecast": {"": W, ...}, # PV-Prognose für den jeweiligen Tag, wächst/verfeinert -# # sich mit jedem Logging-Zyklus # } @@ -144,13 +142,6 @@ def save_log(): new_colors = get_colors(content["entries"][-1]) existing_colors.update(new_colors) content["colors"] = existing_colors - existing_forecast = content.get("forecast") - if not isinstance(existing_forecast, dict): - existing_forecast = {} - new_forecast = get_forecast_values() - if isinstance(new_forecast, dict): - existing_forecast.update(new_forecast) - content["forecast"] = existing_forecast write_and_check(filepath, content) return content["entries"] except Exception: @@ -158,14 +149,6 @@ def save_log(): return None -def get_forecast_values() -> Dict: - try: - return data.data.optional_data.data.forecast.get.today_values or {} - except Exception: - log.exception("Fehler beim Speichern der Prognosewerte") - return {} - - def get_previous_entry(parent_file: Path, content: Dict) -> Optional[Dict]: try: previous_entry = content["entries"][-1] diff --git a/packages/helpermodules/measurement_logging/write_log_test.py b/packages/helpermodules/measurement_logging/write_log_test.py index 915f72ae84..2616d62108 100644 --- a/packages/helpermodules/measurement_logging/write_log_test.py +++ b/packages/helpermodules/measurement_logging/write_log_test.py @@ -133,7 +133,6 @@ def test_save_log_keeps_existing_names_and_colors_when_writing_new_entry(monkeyp "entries": [{"timestamp": 1, "cp": {"cp1": {"imported": 1, "exported": 0}}}], "names": {"cp1": "Bestehender Ladepunkt"}, "colors": {"cp1": "#111111"}, - "forecast": {"1": 100.0}, } mocked_file = Mock() mocked_file.__enter__ = Mock(return_value=mocked_file) @@ -149,7 +148,6 @@ def test_save_log_keeps_existing_names_and_colors_when_writing_new_entry(monkeyp monkeypatch.setattr(write_log, "get_names", Mock( return_value={"cp1": "Bestehender Ladepunkt", "cp2": "Neuer Ladepunkt"})) monkeypatch.setattr(write_log, "get_colors", Mock(return_value={"cp1": "#111111", "cp2": "#222222"})) - monkeypatch.setattr(write_log, "get_forecast_values", Mock(return_value={"2": 200.0})) write_and_check_mock = Mock() monkeypatch.setattr(write_log, "write_and_check", write_and_check_mock) @@ -169,47 +167,3 @@ def test_save_log_keeps_existing_names_and_colors_when_writing_new_entry(monkeyp "cp1": "#111111", "cp2": "#222222", } - assert written_content["forecast"] == { - "1": 100.0, - "2": 200.0, - } - - -def test_get_forecast_values_reads_real_data_path(monkeypatch): - monkeypatch.setattr( - write_log.data, "data", - Mock(optional_data=Mock(data=Mock(forecast=Mock(get=Mock(today_values={"123": 456.0})))))) - - assert write_log.get_forecast_values() == {"123": 456.0} - - -def test_get_forecast_values_falls_back_to_empty_dict_on_error(monkeypatch): - # eg. data.data not fully initialized yet - None has no .optional_data, raising AttributeError - monkeypatch.setattr(write_log.data, "data", None) - - assert write_log.get_forecast_values() == {} - - -def test_save_log_ignores_non_dict_forecast_values(monkeypatch): - # a malformed/corrupted forecast value must not abort the whole daily-log write - matches - # the existing defensive handling for "names"/"colors" in the same function - existing_content = {"entries": [{"timestamp": 1}], "names": {}, "colors": {}, "forecast": {"1": 100.0}} - mocked_file = Mock() - mocked_file.__enter__ = Mock(return_value=mocked_file) - mocked_file.__exit__ = Mock(return_value=None) - mocked_file.read = Mock(return_value=json.dumps(existing_content)) - monkeypatch.setattr("builtins.open", Mock(return_value=mocked_file)) - monkeypatch.setattr(write_log.timecheck, "create_timestamp_YYYYMMDD", Mock(return_value="20261002")) - monkeypatch.setattr(write_log, "get_previous_entry", Mock(return_value=existing_content["entries"][-1])) - monkeypatch.setattr(write_log, "LegacySmartHomeLogData", Mock(return_value=Mock(sh_names={}, sh_dict={}))) - monkeypatch.setattr(write_log, "create_entry", Mock(return_value={"timestamp": 2})) - monkeypatch.setattr(write_log, "get_names", Mock(return_value={})) - monkeypatch.setattr(write_log, "get_colors", Mock(return_value={})) - monkeypatch.setattr(write_log, "get_forecast_values", Mock(return_value=["not", "a", "dict"])) - write_and_check_mock = Mock() - monkeypatch.setattr(write_log, "write_and_check", write_and_check_mock) - - write_log.save_log() - - written_content = write_and_check_mock.call_args.args[1] - assert written_content["forecast"] == {"1": 100.0}