From 786cc1c4fba0c2124db1eba3441a14b8d5f8a01e Mon Sep 17 00:00:00 2001 From: hashtagKnorke <61380298+hashtagKnorke@users.noreply.github.com> Date: Fri, 8 Nov 2024 18:33:43 +0000 Subject: [PATCH 01/10] add thermia online API code as submodule adding the basic docker file and build script add initial integration for Thermia API submodule fix inconsistencies fix NPE fix comparison add debug logging str activate MQTT for reatpump iterate properties instead of attrs handle property type and convert to mqtt supported type if necessary handle network errors fix args fix initializer dummy class for heatpump interface in case no heatpump intergation wanted add high price strategy handling rework baseclass enable Thermia or default dummy heatpump impl add heatpump parameter handling based on net consumption and pricing update submodule to latest commit for thermia_online_api fork update mode names, identifiers and log timmestamp formatting add dependabot config file new strategy: assign most energy conserving modes to most expensive hours adjust the hour offsets for hour end refactor thermia heatpump code to thermia class, add SilentHeatpump class and enhance Heatpump factory logic update subproject commit reference for thermia_online_api add numpy import to baseclass for heatpump implementation add numpy import to Thermia module enhance ThermiaHeatpump logic with additional heat modes (hot water boost and increased temperature heat ) and improved price handling commented-out code for adding thermia_online_api to Python path##hackOFF import datetime fix: correct range_duration calculation in ThermiaHeatpump class refactor: update ThermiaHeatpump initialization to accept config dict make strategy parameters instance vars instead of local config enhance: add configuration parameters for various heat modes in ThermiaHeatpump and fetch from config refactor: replace local variables with instance variables in ThermiaHeatpump mode calculations add timezone of batcontrol to init params enhance: add batcontrol timezone parameter to ThermiaHeatpump initialization - refactor: rename method _plan_for_high_price_window to ensure_strategy_for_time_window for clarity - load strategy config from config file refactor: rename HighPriceHandlingStrategy to ThermiaHighPriceHandling and introduce ThermiaStrategySlot for improved strategy management chore: update subproject commit reference in thermia_online_api refactor: improve logging and update MQTT publishing for high price strategies refactor: update min energy surplus for increased heat and enhance MQTT logging for configuration and strategy values refactor: streamline MQTT publishing for configuration and strategy values, and enhance error logging refactor: fix timestamp formatting in MQTT publishing for high price handlers and strategies refactor: rename mqtt_api to mqtt_client for consistency and clarity in ThermiaHeatpump class refactor: remove unnecessary self reference in install_schedule_in_heatpump method and add type hints for parameters refactor: add comprehensive heat pump configuration options to batcontrol_config_dummy.yaml refactor: simplify debug logging for MQTT configuration publishing in ThermiaHeatpump class refactor: ensure proper cleanup of heatpump reference in Batcontrol destructor refactor: implement destructor to clean up high price handlers and delete schedules in ThermiaHeatpump class refactor: remove commented-out api_set_max_grid_charge_rate method in ThermiaHeatpump class refactor: update type hints and improve constructor documentation in ThermiaHeatpump class refactor: enhance logging for high price strategy planning and streamline handler creation in ThermiaHeatpump class refactor: update cleanup methods to use aware datetime and improve logging in ThermiaHeatpump class refactor: rename timezone variable to heatpump_timezone and update datetime usage for consistency in ThermiaHeatpump class refactor: ensure datetime objects are timezone aware in ThermiaHeatpump cleanup methods update .gitignore to exclude all test files matching test*.py pattern fix: adjust timezone handling for high price strategies and handlers fix: update timezone handling in ThermiaHeatpump class fix: enhance logging and cleanup for high price strategies and handlers Implement retry logic for MQTT connection attempts with logging Add optional retry configuration for MQTT connection in batcontrol_config_dummy.yaml rebase to integrate reced improvements on main branch: MQTT retry configuration fix: update datetime type annotations and improve timestamp comparisons in Thermia classes feat: extend ThermiaStrategySlot to include price and consumption attributes, and publish additional strategy details to MQTT fix: add timestamp publishing for strategy start time and improve handler attribute check fix: update datetime handling to use batcontrol timezone for accurate time calculations fix: ensure correct timezone handling for already planned until in ThermiaHeatpump initialization chore: update subproject reference in thermia_online_api - adapt to chaged Class name: Schedule-->CalendarSchedule - reformatting with linter style: improve code formatting and consistency across heatpump classes chore: update subproject reference in thermia_online_api feat: add method to delete all MQTT topics with a specified prefix refactor: - streamline MQTT topic handling and improve code clarity - clean replanning when new data becomes available - deleting mqtt subtree about handlers and strategies whenever publishing new style: improve code formatting and consistency in Thermia.py feat: refactor MQTT publishing logic into a dedicated method feat: enhance MQTT publishing with additional start and end time topics refactor and docs style: clean up code formatting and remove unnecessary line breaks in Thermia.py feat: add debug logging for topic deletion process in MQTT_API feat: add debug logging in ThermiaHeatpump destructor for high price handler cleanup fix: fully qualified topic needed in delete all topics feat: add docstring to delete_all_topics method and improve topic prefix handling fix: ensure prefix is trimmed and improve topic deletion logging in MQTT_API feat: add debug logging for high price handler and strategy cleanup in MQTT publishing feat: enhance build_docker.sh to include current commit SHA and versioning -- consuned by DOCKERFILE feat: add ThermiaOnlineAPI module and heapump codebase to Docker image feat: implement shutdown method for heatpump and integrate it into batcontrol cleanup revert mistake: set delay evaluation by seconds in ForecastSolar initialization feat: update build_docker.sh to include build arguments for GIT_SHA and VERSION refactor: update MQTT API import and type hints in Thermia and baseclass modules to reflect name refactiorings that happened upstream refactor: reduce sleep duration in MQTT message processing from 20 seconds to 3 seconds refactor: enhance HeatpumpBaseclass documentation and format to address pylint suggestions refactor: - restructure heatpump module and update class references for consistency - follow pylint suggestions refactor: update import statements in DummyHeatpump and SilentHeatpump to use relative imports refactor: rename SilentHeatpump module and update class implementation with improved documentation refactor: rename DummyHeatpump module to dummy_heatpump and enhance class documentation refactor: update import statement for ThermiaHeatpump to use lowercase module name refactor: formatting, docstrings and import statements refactor: add NoHeatPumpsFoundException for better error handling refactor: remove ensure_strategy_for_time_window method from base and dummy heatpump classes, pushing it down into Thermia specific implementation refactor: linting suggestions applied refactor: remove unused import and improve NoHeatPumpsFoundException docstring formatting fix: using indexes in MQTT strategy publish to have a static set tof topics that are not extended over time refactor: replace mode with schedule functionId in MQTT publish for improved clarity fix: ensure functionId is converted to string for MQTT publish formatting feat: implement schedule validation and cleanup for ThermiaHeatpump fix: ensure timestamp comparisons for schedule deletion are accurate HACK: add dynamic path adjustment for ThermiaOnlineAPI module import HACK docs: enhance HOWITWORKS and README with Thermia heat pump integration details docs: update HOWITWORKS to clarify heat pump mode descriptions and energy-saving strategies fix: update _get_all_properties method to include method variable in loop --- .github/dependabot.yml | 20 + .gitignore | 3 +- .gitmodules | 3 + Dockerfile | 7 + HOWITWORKS.md | 83 ++ README.MD | 3 +- batcontrol.py | 26 +- build_docker.sh | 20 + config/batcontrol_config_dummy.yaml | 26 + forecastsolar/fcsolar.py | 2 +- heatpump/__init__.py | 0 heatpump/baseclass.py | 110 +++ heatpump/dummy_heatpump.py | 57 ++ heatpump/heatpump.py | 63 ++ heatpump/silent_heatpump.py | 41 + heatpump/thermia_heatpump.py | 1280 +++++++++++++++++++++++++++ mqtt_api.py | 38 + thermia_online_api | 1 + 18 files changed, 1779 insertions(+), 4 deletions(-) create mode 100644 .github/dependabot.yml create mode 100644 .gitmodules create mode 100755 build_docker.sh create mode 100644 heatpump/__init__.py create mode 100644 heatpump/baseclass.py create mode 100644 heatpump/dummy_heatpump.py create mode 100644 heatpump/heatpump.py create mode 100644 heatpump/silent_heatpump.py create mode 100644 heatpump/thermia_heatpump.py create mode 160000 thermia_online_api diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..8e819cce --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,20 @@ +# To get started with Dependabot version updates, you'll need to specify which +# package ecosystems to update and where the package manifests are located. +# Please see the documentation for more information: +# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates +# https://containers.dev/guide/dependabot + +version: 2 +updates: + - package-ecosystem: "devcontainers" + directory: "/" + schedule: + interval: weekly + - package-ecosystem: "docker" + directory: "/" + schedule: + interval: weekly + - package-ecosystem: "pip" + directory: "/" + schedule: + interval: weekly diff --git a/.gitignore b/.gitignore index 82bf2924..687601bf 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ *.vscode* *venv* log -test.py +test*.py config/batcontrol_config.yaml.bck run_infinite.sh +debug.txt diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 00000000..865c40bf --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "thermia_online_api"] + path = thermia_online_api + url = https://github.com/hashtagKnorke/thermia-online-api.git diff --git a/Dockerfile b/Dockerfile index 97102cae..dbf0f58c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -32,6 +32,13 @@ COPY inverter ./inverter COPY forecastconsumption ./forecastconsumption COPY forecastsolar ./forecastsolar COPY logfilelimiter ./logfilelimiter +COPY heatpump ./heatpump + +# ! module ThermiaOnlineAPI checked out into Git submodule thermia_online_api, +# so we need to copy it to path ThermiaOnlineAPI +# to make it available as Python module on python path +# in the Docker image +COPY thermia_online_api/ThermiaOnlineAPI ./ThermiaOnlineAPI COPY entrypoint.sh ./ RUN chmod +x entrypoint.sh diff --git a/HOWITWORKS.md b/HOWITWORKS.md index 9ac41670..4019c015 100644 --- a/HOWITWORKS.md +++ b/HOWITWORKS.md @@ -29,3 +29,86 @@ If your consumption exceeds your current production energy from the grid will be The battery is charged from the grid at a certain charge rate. This mode calculates the estimated required energy for future hours with high electricity prices. The objective is to charge the battery enough so that you do not need to consume energy from the grid in these hours with high prices. The difference in price is configured with ``min_price_difference``. Charging and Discharging has losses of up to 20%, depending on set-up. This should be considered in the configuration depending on your actual set-up. How fast the battery can be charged via the grid is defined with the ``max_grid_charge_rate`` configuration. There is a seperate general upper recharge limit is ``max_charging_from_grid_limit``. + +## Heatpump integration +### Thermia Heatpump Strategy + +The Thermia heat pump integration in this software is designed to optimize the operation of your heat pump based on electricity prices, energy consumption, and energy production forecasts. The strategy aims to minimize energy costs by adjusting the heat pump's operating modes according to predefined rules and configurations. + +#### Thermia Online API Extension + +The Thermia Online API (https://github.com/klejejs/python-thermia-online-api) has been forked and extended (https://github.com/hashtagKnorke/thermia-online-api/tree/add_calendars) to leverage the Calendar function schedule API of Thermia Online API for setting up the behavior of the Thermia heat pump. This extension allows to control the heat pump based on predefined schedules and energy price forecasts. + +##### Integration of the fork +The changes in the fork have been raised as a PR (https://github.com/klejejs/python-thermia-online-api/pull/48) so that they might converge into the mainstream library. For the meantime, the fork has been integrated into the batcontrol repo as a a submodule, integrating the library as sourcecode during dockerfile build. +In case the directory is not in path a small hack in (https://github.com/hashtagKnorke/batcontrol/blob/be5f4eb2df73936234807a4ff355b7d1a9da882e/heatpump/thermia_heatpump.py#L36) tries to add the subdir to the pyton path so that the import succeeds. + +##### Key Enhancements + +1. **Calendar Function Integration**: The API now supports the Calendar function, enabling users to define and manage schedules for the heat pump's operation. This allows for automated adjustments to the heat pump's modes based on time and energy price forecasts. + +2. **Enhanced Scheduling**: Users can create, update, and delete schedules for the heat pump. These schedules can specify different operating modes for different times of the day, optimizing energy usage and cost savings. + +##### API Methods + +The following methods have been added to the Thermia API to support the Calendar function: + +- **get_schedules(installation_id: str)**: Retrieves the schedules for a given installation. +- **add_new_schedule(installation_id: str, data: dict)**: Adds a new schedule for a given installation. +- **delete_schedule(installation_id: str, schedule_id: int)**: Deletes a schedule for a given installation. + +These methods allow for full control over the scheduling of the heat pump's operation, enabling users to optimize energy usage and minimize costs effectively. + +#### Benefits + +- **Cost Savings**: By scheduling the heat pump to operate in energy-saving modes during high price periods, users can significantly reduce their energy costs. +- **Automation**: The integration with the Calendar function allows for automated control of the heat pump, reducing the need for manual adjustments. +- **Flexibility**: Users can define multiple schedules with different operating modes, providing flexibility to adapt to changing energy prices and consumption patterns. + +To get started with the extended Thermia Online API, refer to the documentation and configure the necessary settings in the `config/batcontrol_config.yaml` file. + +#### Key Components + +1. **ThermiaHighPriceHandling**: Manages settings to handle high price periods. +2. **ThermiaStrategySlot**: Represents a strategy decision for a specific time slot. +3. **ThermiaHeatpump**: The main class that manages and controls the Thermia heat pump. + +#### Strategy Overview + +The strategy involves setting the heat pump to the most energy-saving mode during high price periods while considering the following modes: + +- **E: EVU Block**: Activated when electricity prices are high. Maximum energy saving, deactivating heating and Hot water production. +- **B: Hot Water Block**: Activated to block hot water production during high price periods. +- **R: Reduced Heat**: Lowers the heating effect to save energy. +- **N: Normal mode**: No adjustments to heatpump behaviour. +- **H: Increased Heat**: Increases heating when energy is cheap or there is a PV surplus. +- **W: Hot Water Boost**: Boosts hot water production when there is an energy surplus. + +#### Configuration Parameters + +- **min_price_for_evu_block**: Minimum price to trigger EVU block mode. +- **max_evu_block_hours**: Maximum hours per day for EVU block mode. +- **max_evu_block_duration**: Maximum continuous duration for EVU block mode. +- **min_price_for_hot_water_block**: Minimum price to trigger hot water block mode. +- **max_hot_water_block_hours**: Maximum hours per day for hot water block mode. +- **max_hot_water_block_duration**: Maximum continuous duration for hot water block mode. +- **min_price_for_reduced_heat**: Minimum price to trigger reduced heat mode. +- **max_reduced_heat_hours**: Maximum hours per day for reduced heat mode. +- **max_reduced_heat_duration**: Maximum continuous duration for reduced heat mode. +- **reduced_heat_temperature**: Temperature setting for reduced heat mode. +- **max_price_for_increased_heat**: Maximum price to trigger increased heat mode. +- **min_energy_surplus_for_increased_heat**: Minimum energy surplus to trigger increased heat mode. +- **max_increased_heat_hours**: Maximum hours per day for increased heat mode. +- **max_increased_heat_duration**: Maximum continuous duration for increased heat mode. +- **increased_heat_temperature**: Temperature setting for increased heat mode. +- **max_increased_heat_outdoor_temperature**: Maximum outdoor temperature for increased heat mode. +- **min_energy_surplus_for_hot_water_boost**: Minimum energy surplus to trigger hot water boost mode. +- **max_hot_water_boost_hours**: Maximum hours per day for hot water boost mode. + +#### Operation + +The software continuously monitors electricity prices, energy consumption, and production forecasts. Based on these inputs and the current state of charge (SOC) of the battery, it dynamically adjusts the heat pump's operating mode to optimize energy usage and minimize costs. + +The strategy is recalculated every three minutes to ensure that the heat pump operates in the most cost-effective manner, taking into account the latest forecasts and current conditions. + +To configure the Thermia heat pump integration, you will need to adapt the settings in `config/batcontrol_config.yaml`. diff --git a/README.MD b/README.MD index 126b6778..07457910 100644 --- a/README.MD +++ b/README.MD @@ -23,10 +23,11 @@ To integrate batcontrol with Home Assistant, use the following repository: [ha_a ## Installation: +(i) This contains some temporary hacky adjustments for Thermia online API integration (see [Thermia API library](https://github.com/hashtagKnorke/batcontrol/blob/be5f4eb2df73936234807a4ff355b7d1a9da882e/HOWITWORKS.md#L38) for details) ## Install: ```sh -git clone https://github.com/muexxl/batcontrol.git +git clone https://github.com/muexxl/batcontrol.git --recurse-submodules cd batcontrol virtualenv venv source venv/bin/activate diff --git a/batcontrol.py b/batcontrol.py index 2ac953ae..17a51ba7 100755 --- a/batcontrol.py +++ b/batcontrol.py @@ -13,6 +13,11 @@ from dynamictariff import dynamictariff as tariff_factory from inverter import inverter as inverter_factory from logfilelimiter import logfilelimiter +from heatpump import heatpump + +## Add the subdirectory to the Python path +#sys.path.append(os.path.join(os.path.dirname(__file__), 'thermia_online_api')) + from forecastsolar import solar as solar_factory @@ -110,6 +115,9 @@ def __init__(self, configfile): DELAY_EVALUATION_BY_SECONDS ) + self.inverter = inverter_factory.Inverter.create_inverter(config['inverter']) + self.heatpump = heatpump.Heatpump(config['heatpump'], timezone) + self.inverter = inverter_factory.Inverter.create_inverter(config['inverter']) self.pvsettings = config['pvinstallations'] @@ -169,8 +177,14 @@ def __init__(self, configfile): self.api_set_min_price_difference, float ) + logger.info(f'[Main] MQTT Callbacks registered ') + # Inverter Callbacks self.inverter.activate_mqtt(self.mqtt_api) + # Heatpump Callbacks + self.heatpump.activate_mqtt(self.mqtt_api) + logger.info(f'[Main] MQTT Connection to Heatpump ready ') + self.evcc_api = None if 'evcc' in config.keys(): @@ -181,12 +195,16 @@ def __init__(self, configfile): self.evcc_api.register_block_function(self.set_discharge_blocked) self.evcc_api.wait_ready() logger.info('[Main] EVCC Connection ready') - + + def shutdown(self): logger.info('[Main] Shutting down Batcontrol') try: self.inverter.shutdown() + # todo: shutdown other components del self.inverter + self.heatpump.shutdown() + del self.heatpump except: pass @@ -430,6 +448,10 @@ def run(self): datetime.datetime.now().astimezone(self.timezone).minute/60 self.set_wr_parameters(net_consumption, price_dict) + self.heatpump.set_heatpump_parameters(net_consumption, price_dict) + + # %% + # %% def set_wr_parameters(self, net_consumption: np.ndarray, prices: dict): @@ -839,6 +861,8 @@ def refresh_static_values(self): self.mqtt_api.publish_discharge_blocked(self.discharge_blocked) # Trigger Inverter self.inverter.refresh_api_values() + if self.heatpump is not None: + self.heatpump.refresh_api_values() def api_set_mode(self, mode: int): # Check if mode is valid diff --git a/build_docker.sh b/build_docker.sh new file mode 100755 index 00000000..5f805552 --- /dev/null +++ b/build_docker.sh @@ -0,0 +1,20 @@ +#!/bin/sh + +# Get the current commit SHA +GIT_SHA=$(git rev-parse HEAD) + +# Get the current Git reference name (branch or tag) +GIT_REF_NAME=$(git symbolic-ref -q --short HEAD || git describe --tags --exact-match) + +# Set the VERSION value +if [ -n "$GIT_REF_NAME" ]; then + VERSION=$GIT_REF_NAME +else + VERSION="snapshot" +fi + +# Print the VERSION value +echo "SHA: $GIT_SHA .. VERSION: $VERSION" + +# Build the Docker image with build arguments +docker buildx build . -t hashtagknorke:batcontrol --build-arg GIT_SHA=$GIT_SHA --build-arg VERSION=$VERSION \ No newline at end of file diff --git a/config/batcontrol_config_dummy.yaml b/config/batcontrol_config_dummy.yaml index 688fde8a..8d23a560 100644 --- a/config/batcontrol_config_dummy.yaml +++ b/config/batcontrol_config_dummy.yaml @@ -37,6 +37,32 @@ mqtt: keyfile: /etc/ssl/certs/client.key tls_version: tlsv1.2 +heatpump: + type: Thermia # [Thermia, none] + user: "your_username" # Username for accessing the heat pump system + password: "your_password" # Password for accessing the heat pump system + # EVU block settings + min_price_for_evu_block: 0.6 # Minimum price threshold to block EVU (Energy Supply Company) usage + max_evu_block_hours: 14 # Maximum number of hours to block EVU usage per day + max_evu_block_duration: 6 # Maximum duration for a single EVU block event in hours + # Hot water block settings + min_price_for_hot_water_block: 0.4 # Minimum price threshold to block hot water usage + max_hot_water_block_hours: 10 # Maximum number of hours to block hot water usage per day + max_hot_water_block_duration: 4 # Maximum duration for a single hot water block event in hours + #reduced heat settings + min_price_for_reduced_heat: 0.3 # Minimum price threshold to reduce heating + max_reduced_heat_hours: 14 # Maximum number of hours to reduce heating per day + max_reduced_heat_duration: 6 # Maximum duration for a single reduced heating event in hours + reduced_heat_temperature: 20 # Target temperature when heating is reduced + #increased heat settings + max_price_for_increased_heat: 0.2 # Maximum price threshold to increase heating + min_energy_surplus_for_increased_heat: 1000 # Minimum energy surplus required to increase heating + max_increased_heat_hours: 14 # Maximum number of hours to increase heating per day + max_increased_heat_duration: 6 # Maximum duration for a single increased heating event in hours + increased_heat_temperature: 22 # Target temperature when heating is increased + max_increased_heat_outdoor_temperature: 15 # Maximum outdoor temperature to allow increased heating + + pvinstallations: - name: Haus #name diff --git a/forecastsolar/fcsolar.py b/forecastsolar/fcsolar.py index d6d65c7b..a7127331 100644 --- a/forecastsolar/fcsolar.py +++ b/forecastsolar/fcsolar.py @@ -26,7 +26,7 @@ def __init__(self, pvinstallations, timezone, self.seconds_between_updates = 900 self.timezone=timezone self.rate_limit_blackout_window = 0 - self.delay_evaluation_by_seconds=delay_evaluation_by_seconds + self.delay_evaluation_by_seconds = delay_evaluation_by_seconds def get_forecast(self) -> dict: """ Get hourly forecast from provider """ diff --git a/heatpump/__init__.py b/heatpump/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/heatpump/baseclass.py b/heatpump/baseclass.py new file mode 100644 index 00000000..92fb9601 --- /dev/null +++ b/heatpump/baseclass.py @@ -0,0 +1,110 @@ +""" +Parent Class for implementing Heatpumps and test drivers +""" + +import numpy as np +from mqtt_api import MqttApi + + +class HeatpumpBaseclass(): + """ " + HeatpumpBaseclass is a base class for heat pump systems, providing a structure for implementing + MQTT functionality, refreshing API values, generating MQTT topics, + planning for high price windows, setting heat pump parameters, and shutting down the system. + + Methods: + activate_mqtt(mqtt_api: mqtt_api.MqttApi): + Activates the MQTT functionality for the heat pump. Must be implemented by subclasses. + + refresh_api_values(): + Refreshes the API values for the heat pump. Must be implemented by subclasses. + + _get_mqtt_topic() -> str: + + ensure_strategy_for_time_window(start_time: datetime, end_time: datetime): + Plans for high price window. Must be implemented by subclasses. + + set_heatpump_parameters(net_consumption: np.ndarray, prices: dict): + Sets the parameters for the heat pump based on net energy consumption and energy prices. + Must be implemented by subclasses. + + shutdown(): + Shuts down the system, performing any necessary cleanup. + """ + + def activate_mqtt(self, mqtt_api: MqttApi): + """ + Activates the MQTT functionality for the heat pump. + + This method should be implemented by subclasses to provide the specific + MQTT activation logic. + + Args: + mqtt_api (mqtt_api.MqttApi): An instance of the MqttApi class to handle + MQTT operations. + + Raises: + Error: If the method is not implemented by the subclass. + """ + raise RuntimeError( + "[Heatpump Base Class] Function 'activate_mqtt' not implemented" + ) + + def refresh_api_values(self): + """ + Refreshes the API values for the heat pump. + + This method should be implemented by subclasses to update the heat pump's + data from the API. If not implemented, it raises a RuntimeError. + + Raises: + RuntimeError: If the method is not implemented in the subclass. + """ + raise RuntimeError( + "[Heatpump Base Class] Function 'refresh_api_values' not implemented" + ) + + # Used to implement the mqtt basic topic. + # Currently there is only one Heatpump, so the number is hardcoded + def _get_mqtt_topic(self): + """ + Generates the MQTT topic for the heat pump. + + Returns: + str: The MQTT topic string for the heat pump. + """ + return "heatpumps/0/" + + def set_heatpump_parameters(self, net_consumption: np.ndarray, prices: dict): + """ + Set the parameters for the heat pump based on net energy consumption and energy prices. + Parameters: + ----------- + net_consumption : np.ndarray + An array representing the net energy consumption for each hour. + prices : dict + A dictionary where keys are hours and values are the corresponding energy prices. + Returns: + -------- + None + """ + raise RuntimeError( + "[Heatpump Base Class] Function 'set_heatpump_parameters' not implemented" + ) + + def shutdown(self): + """ + Shuts down the system. + + This method is intended to perform any necessary cleanup and safely shut down the system. + """ + pass # default impl does nothing, pylint: disable=unnecessary-pass +class NoHeatPumpsFoundException(Exception): + """ + Exception raised when no heat pumps are found + in the configuration or the configured user account. + """ + + def __init__(self, message="No heat pumps found in the configuration"): + self.message = message + super().__init__(self.message) diff --git a/heatpump/dummy_heatpump.py b/heatpump/dummy_heatpump.py new file mode 100644 index 00000000..cdfdda43 --- /dev/null +++ b/heatpump/dummy_heatpump.py @@ -0,0 +1,57 @@ +""" +DummyHeatpump module + +This module contains the DummyHeatpump class, which is a subclass of HeatpumpBaseclass. +It provides dummy implementations for various heat pump operations such as activating MQTT, +refreshing API values, planning for high price windows, and setting heat pump parameters. + +Classes: + DummyHeatpump: A dummy implementation of a heat pump for testing purposes. + +""" + +import logging +from .baseclass import HeatpumpBaseclass + +# Configure the logger +logger = logging.getLogger("__main__") + + +class DummyHeatpump(HeatpumpBaseclass): + """ + DummyHeatpump is a subclass of HeatpumpBaseclass + that simulates the behavior of a heat pump for testing purposes. + + Methods: + __init__(): + Initializes the DummyHeatpump instance. + + activate_mqtt(param): + Activates MQTT for the DummyHeatpump and logs the MQTT topic. + + refresh_api_values(): + Refreshes the API values for the DummyHeatpump. + + ensure_strategy_for_time_window(start_time, end_time): + Plans for a high price window between the specified start and end times. + + set_heatpump_parameters(net_consumption, prices): + Sets the heat pump parameters using the provided net consumption and prices. + """ + + def __init__(self): + pass + + def activate_mqtt(self, mqtt_api): + logger.info("[DummyHeatpump] Activating MQTT with param: %s", mqtt_api) + logger.debug("[DummyHeatpump] MQTT topic: %s", self._get_mqtt_topic()) + + def refresh_api_values(self): + logger.info("[DummyHeatpump] Refreshing API values") + + def set_heatpump_parameters(self, net_consumption, prices): + logger.info( + "[DummyHeatpump] Setting heat pump parameters with net consumption %s and prices %s", + net_consumption, + prices, + ) diff --git a/heatpump/heatpump.py b/heatpump/heatpump.py new file mode 100644 index 00000000..47c5f53a --- /dev/null +++ b/heatpump/heatpump.py @@ -0,0 +1,63 @@ +"""" +Heatpump module + +This module provides a factory class for creating instances of different types of heat pumps +based on the provided configuration. The supported heat pump types are 'thermia', 'dummy', +and a default 'silent' type. + +Classes: + Heatpump: A factory class that returns an instance of a specific heat pump type based on + the provided configuration. + +Dependencies: + pytz: A library for accurate and cross platform timezone calculations. +""" + +import pytz +from .dummy_heatpump import DummyHeatpump +from .silent_heatpump import SilentHeatpump +from .thermia_heatpump import ThermiaHeatpump + + +class Heatpump: + """ + Heatpump class factory that returns an instance of a specific heat pump type + based on the provided configuration. + + Args: + config (dict): Configuration dictionary containing the type of heat pump and necessary + credentials. + timezone (pytz.timezone): Timezone information for the heat pump. + + Returns: + ThermiaHeatpump: If the type specified in the config is 'thermia'. + DummyHeatpump: If the type specified in the config is 'dummy'. + SilentHeatpump: If the type specified in the config is neither 'thermia' nor 'dummy'. + + Raises: + KeyError: If the 'type' key is not present in the config dictionary. + """ + + def __new__(cls, config: dict, timezone: pytz.timezone): + if config is None: # pylint: disable=no-else-return + return cls.default() + elif "type" not in config: + return cls.default() + else: + if config["type"].lower() == "thermia": # pylint: disable=no-else-return + return ThermiaHeatpump(config, timezone) + elif config["type"].lower() == "dummy": + return DummyHeatpump() + else: + return cls.default() + + @staticmethod + def default(): + """ + Create and return the default implementation, currently an instance of SilentHeatpump. + + Returns: + SilentHeatpump: An instance of the SilentHeatpump class. + """ + + return SilentHeatpump() diff --git a/heatpump/silent_heatpump.py b/heatpump/silent_heatpump.py new file mode 100644 index 00000000..faa96557 --- /dev/null +++ b/heatpump/silent_heatpump.py @@ -0,0 +1,41 @@ +""" +SilentHeatpump Module + +This module contains the SilentHeatpump class, which inherits from the HeatpumpBaseclass. +The SilentHeatpump class is a silent stub that does nothing and does not create any logging noise. + +Classes: + SilentHeatpump: A class that represents a silent heat pump with no operational functionality. + +""" +import logging +from .baseclass import HeatpumpBaseclass + +# Configure the logger +logger = logging.getLogger("__main__") + + +class SilentHeatpump(HeatpumpBaseclass): + """ + SilentHeatpump class inherits from HeatpumpBaseclass and is a silent stub that + does nothing and does not create any logging noise. + """ + + def __init__(self): + logger.info("[SilentHeatpump] Initializing SilentHeatpump") + pass # default impl does nothing, pylint: disable=unnecessary-pass + + def activate_mqtt(self, mqtt_api): + """ + Activates the MQTT functionality for the heat pump. + + Args: + mqtt_api: An instance of the MQTT API to be used for communication. + """ + pass # default impl does nothing, pylint: disable=unnecessary-pass + + def refresh_api_values(self): + pass + + def set_heatpump_parameters(self, net_consumption, prices): + pass diff --git a/heatpump/thermia_heatpump.py b/heatpump/thermia_heatpump.py new file mode 100644 index 00000000..322737b5 --- /dev/null +++ b/heatpump/thermia_heatpump.py @@ -0,0 +1,1280 @@ +# pylint: disable=too-many-lines +# pylint: disable=wrong-import-position +""" +This module provides classes and methods for managing and controlling a Thermia heat pump system, +including handling high price periods and applying various strategies based on energy consumption +and prices. +Classes: + ThermiaHighPriceHandling: Represents an applied setting to handle high price periods + for the Thermia heat pump system. + ThermiaStrategySlot: Represents a strategy decision for a certain time slot. + ThermiaHeatpump: Manages and controls a Thermia heat pump, + providing methods to initialize the heat pump, + fetch configuration parameters, + activate MQTT, refresh API values, + set heat pump parameters, adjust mode duration, + apply modes, ensure strategies for time windows, + install schedules, clean up high price strategies and handlers, + and publish strategies to MQTT. + logger: Logger instance for logging messages. +""" + +from dataclasses import dataclass +import inspect +import logging +import datetime +from typing import Optional +import sys +import os +import pytz +import numpy as np + + +logger = logging.getLogger("__main__") +logger.info("[Heatpump] loading module ") + +#### hack to add the submodule to the python path before import fails +if os.path.isdir("thermia_online_api") and os.path.exists("thermia_online_api/ThermiaOnlineAPI"): + sys.path.append(os.path.abspath("thermia_online_api")) + logger.warning("ThermiaOnlineAPI module added to Python path from 'thermia_online_api' " + +"subdirectory. This is a hack because of the forked library being " + +"integrated as submodule in a subdir instead of root dir.") + +from ThermiaOnlineAPI.const import ( + CAL_FUNCTION_EVU_MODE, + CAL_FUNCTION_HOT_WATER_BLOCK, + CAL_FUNCTION_REDUCED_HEATING_EFFECT, +) +from ThermiaOnlineAPI.model.HeatPump import ThermiaHeatPump +from ThermiaOnlineAPI.model.CalendarSchedule import CalendarSchedule +from ThermiaOnlineAPI import Thermia +from ThermiaOnlineAPI.utils import utils + +from mqtt_api import MqttApi +from .baseclass import HeatpumpBaseclass, NoHeatPumpsFoundException + + + +@dataclass +class ThermiaHighPriceHandling: + """ + A class representing an applied setting to handle high price periods + for the Thermia heat pump system. + + Attributes: + start_time (datetime.datetime): The start time of the high price period. + end_time (datetime.datetime): The end time of the high price period. + schedule (CalendarSchedule): The schedule associated with the high price period. + """ + + def __init__( + self, + start_time: datetime.datetime, + end_time: datetime.datetime, + schedule: CalendarSchedule, + ): + """ + Initializes the ThermiaHighPriceHandling class with the specified + start time, end time, and schedule. + + Args: + start_time (datetime.datetime): The start time of the high price period. + end_time (datetime.datetime): The end time of the high price period. + schedule (CalendarSchedule): The schedule associated with the high price period. + """ + + self.start_time = start_time + self.end_time = end_time + self.schedule = schedule + + def __repr__(self): + return f"HighPriceHandlingStrategy(schedule={self.schedule})" + + +@dataclass +class ThermiaStrategySlot: + """ + A class to represent a strategy decision for a certain time slot. + + Attributes: + ----------- + start_time : datetime.datetime + The start time of the strategy slot. + end_time : datetime.datetime + The end time of the strategy slot. + mode : str + The mode of operation during the strategy slot. + price : float + The price associated with the strategy slot. + consumption : float + The energy consumption during the strategy slot. + + Methods: + -------- + setHandling(handler: ThermiaHighPriceHandling): + Sets the handler for high price handling. + """ + + def __init__( # pylint: disable=too-many-arguments + self, + start_time: datetime.datetime, + end_time: datetime.datetime, + mode: str, + price: float, + consumption: float, + ): + """ + Initialize a new instance of the Thermia class. + + Args: + start_time (datetime.datetime): The start time of the heat pump operation. + end_time (datetime.datetime): The end time of the heat pump operation. + mode (str): The mode of operation for the heat pump. + price (float): The price of electricity during the operation period. + consumption (float): The energy consumption of the heat pump during + the operation period. + """ + self.start_time = start_time + self.end_time = end_time + self.mode = mode + self.price = price + self.consumption = consumption + self.handler = None + + def associate_handler(self, handler: ThermiaHighPriceHandling): + """ + Sets the handler for high price handling. + + Args: + handler (ThermiaHighPriceHandling): An instance of ThermiaHighPriceHandling + that defines how to handle high price situations. + """ + self.handler = handler + + def __repr__(self): + if hasattr(self, "handler"): # pylint: disable=no-else-return + return ( + f"STRATEGY({self.start_time}-{self.end_time}:" + + f"[{self.mode}]->{self.handler.schedule})" + ) + else: + return f"STRATEGY({self.start_time}-{self.end_time}:[{self.mode}])" + + + + +class ThermiaHeatpump( + HeatpumpBaseclass +): # pylint: disable=too-many-instance-attributes + """ + ThermiaHeatpump class for managing and controlling a Thermia heat pump. + + This class provides methods to initialize the heat pump, fetch configuration parameters, + ensure connection to the heat pump, activate MQTT, refresh API values, set heat pump parameters, + adjust mode duration, apply modes, ensure strategies for time windows, + install schedules in the heat pump, clean up high price strategies and handlers, + and publish strategies to MQTT. + + Attributes: + heat_pump : Optional[ThermiaHeatPump] + Instance of the Thermia heat pump. + mqtt_client : Optional[MQTT_API] + MQTT client for publishing internal values. + high_price_handlers : dict[datetime.datetime, ThermiaHighPriceHandling] + Dictionary to store high price handlers to avoid duplicates and enable removal. + already_planned_until : datetime + Maximum time that has already been planned to avoid double planning. + high_price_strategies : dict[datetime.datetime, ThermiaStrategySlot] + Dictionary to store all strategies for future reference. + min_price_for_evu_block : float + Minimum price for EVU block mode. + max_evu_block_hours : int + Maximum number of hours for EVU block mode. + max_evu_block_duration : int + Maximum duration for EVU block mode. + min_price_for_hot_water_block : float + Minimum price for hot water block mode. + max_hot_water_block_hours : int + Maximum number of hours for hot water block mode. + max_hot_water_block_duration : int + Maximum duration for hot water block mode. + min_price_for_reduced_heat : float + Minimum price for reduced heat mode. + max_reduced_heat_hours : int + Maximum number of hours for reduced heat mode. + max_reduced_heat_duration : int + Maximum duration for reduced heat mode. + reduced_heat_temperature : int + Temperature for reduced heat mode. + max_price_for_increased_heat : float + Maximum price for increased heat mode. + min_energy_surplus_for_increased_heat : int + Minimum energy surplus for increased heat mode. + max_increased_heat_hours : int + Maximum number of hours for increased heat mode. + max_increased_heat_duration : int + Maximum duration for increased heat mode. + increased_heat_temperature : int + Temperature for increased heat mode. + max_increased_heat_outdoor_temperature : int + Maximum outdoor temperature for increased heat mode. + min_energy_surplus_for_hot_water_boost : int + Minimum energy surplus for hot water boost mode. + max_hot_water_boost_hours : int + Maximum number of hours for hot water boost mode. + + Methods: + __init__(config: dict, timezone: pytz.timezone) -> None + fetch_param_from_config(config: dict, name: str, default: float) -> float + Fetch a parameter from the configuration dictionary. + ensure_connection() + Ensure connection to the Thermia heat pump. + activate_mqtt(api_mqtt_api) + Activate MQTT and publish internal values. + refresh_api_values() + Refresh API values and publish them to MQTT. + set_heatpump_parameters(net_consumption: np.ndarray, prices: dict) + adjust_mode_duration(heat_modes: list[str], prices: list[float], + inspected_mode: str, downgrade_mode: str, max_mode_duration: int) + applyMode(mode: str, start_index: int, end_index: int) + Apply the specified mode for the given time range. + ensure_strategy_for_time_window(start_time: datetime, end_time: datetime, mode: str) + Ensure a strategy is present for the specified time window. + install_schedule_in_heatpump(start_time: datetime, end_time: datetime, mode: str) + Install a schedule in the heat pump based on the provided start time, end time, and mode. + cleanupHighPriceStrategies() + cleanupHighPriceHandlers() + publish_strategies_to_mqtt() + Publish high price strategies and handlers to MQTT. + __del__() + """ + + heat_pump: Optional[ThermiaHeatPump] = None + mqtt_client: Optional[MqttApi] = None + + ## store all high price handlers to avoid duplicates and to be able to remove them + high_price_handlers: dict[datetime.datetime, ThermiaHighPriceHandling] = {} + + ## max time that has already been planned, to avoid double planning + already_planned_until: datetime + + ## store all strategies to be able to refer to them later + high_price_strategies: dict[datetime.datetime, ThermiaStrategySlot] = {} + + ## config for the strategy + # Set the maximum number of hours and the maximum duration for each mode + # The strategy is to set the heat pump to the most energy saving mode in time slots + # with the highest price first, but having a maximum number of hours and a + # maximum duration for each mode + # and having a min trigger price for each mode + ### EVU Block + min_price_for_evu_block = 0.6 + max_evu_block_hours = 14 + max_evu_block_duration = 6 + ### Hot Water Block + min_price_for_hot_water_block = 0.4 + max_hot_water_block_hours = 10 + max_hot_water_block_duration = 4 + ### Reduced Heat + min_price_for_reduced_heat = 0.3 + max_reduced_heat_hours = 14 + max_reduced_heat_duration = 6 + reduced_heat_temperature = 18 + ### Increased Heat + max_price_for_increased_heat = 0.2 + min_energy_surplus_for_increased_heat = 500 + max_increased_heat_hours = 14 + max_increased_heat_duration = 6 + increased_heat_temperature = 22 + max_increased_heat_outdoor_temperature = 15 + ### Hot Water Boost + min_energy_surplus_for_hot_water_boost = 2500 + max_hot_water_boost_hours = 1 + + def __init__(self, config: dict, timezone: pytz.timezone) -> None: + """ + Initialize the ThermiaHeatpump instance. + + Parameters: + ----------- + config : dict + Configuration dictionary containing user credentials and other settings. + timezone : pytz.timezone + Timezone of the heat pump installation. + """ + super().__init__() + self.user = config["user"] + self.password = config["password"] + self.__ensure_connection() + self.batcontrol_timezone = timezone + self.already_planned_until = ( + datetime.datetime.now() + .astimezone(self.batcontrol_timezone) + .replace(minute=0, second=0, microsecond=0) + ) + + ## fetch strategy params from config + ### EVU Block + self.min_price_for_evu_block = self.__fetch_param_from_config( + config, "min_price_for_evu_block", 0.6 + ) + self.max_evu_block_hours = self.__fetch_param_from_config( + config, "max_evu_block_hours", 14 + ) + self.max_evu_block_duration = self.__fetch_param_from_config( + config, "max_evu_block_duration", 6 + ) + ### Hot Water Block + self.min_price_for_hot_water_block = self.__fetch_param_from_config( + config, "min_price_for_hot_water_block", 0.4 + ) + self.max_hot_water_block_hours = self.__fetch_param_from_config( + config, "max_hot_water_block_hours", 10 + ) + self.max_hot_water_block_duration = self.__fetch_param_from_config( + config, "max_hot_water_block_duration", 4 + ) + ### Reduced Heat + self.min_price_for_reduced_heat = self.__fetch_param_from_config( + config, "min_price_for_reduced_heat", 0.3 + ) + self.max_reduced_heat_hours = self.__fetch_param_from_config( + config, "max_reduced_heat_hours", 14 + ) + self.max_reduced_heat_duration = self.__fetch_param_from_config( + config, "max_reduced_heat_duration", 6 + ) + self.reduced_heat_temperature = self.__fetch_param_from_config( + config, "reduced_heat_temperature", 20 + ) + ### Increased Heat + self.max_price_for_increased_heat = self.__fetch_param_from_config( + config, "max_price_for_increased_heat", 0.2 + ) + self.min_energy_surplus_for_increased_heat = self.__fetch_param_from_config( + config, "min_energy_surplus_for_increased_heat", 1000 + ) + self.max_increased_heat_hours = self.__fetch_param_from_config( + config, "max_increased_heat_hours", 14 + ) + self.max_increased_heat_duration = self.__fetch_param_from_config( + config, "max_increased_heat_duration", 6 + ) + self.increased_heat_temperature = self.__fetch_param_from_config( + config, "increased_heat_temperature", 22 + ) + self.max_increased_heat_outdoor_temperature = self.__fetch_param_from_config( + config, "max_increased_heat_outdoor_temperature", 15 + ) + ### Hot Water Boost + self.min_energy_surplus_for_hot_water_boost = self.__fetch_param_from_config( + config, "min_energy_surplus_for_hot_water_boost", 2500 + ) + self.max_hot_water_boost_hours = self.__fetch_param_from_config( + config, "max_hot_water_boost_hours", 1 + ) + + def __fetch_param_from_config( + self, config: dict, name: str, default: float + ) -> float: + """ + Fetches a parameter from the configuration dictionary. If the parameter + is not found, returns the provided default value. + + Args: + config (dict): The configuration dictionary to fetch the parameter from. + name (str): The name of the parameter to fetch. + default (float): The default value to return if the parameter is not found. + + Returns: + float: The value of the parameter from the configuration, or the default value. + """ + + if name in config: # pylint: disable=no-else-return + logger.debug( + "[ThermiaHeatpump] fetching %s from config: %s", name, config[name] + ) + return config[name] + else: + logger.debug( + "[ThermiaHeatpump] using default for config %s default: %s", + name, + default, + ) + return default + + def __ensure_connection(self): + """ + Ensures that a connection to the Thermia heat pump is established. + + This method attempts to connect to the Thermia heat pump using the provided + user credentials. If a connection is successfully established, it initializes + the heat pump and logs relevant information. If no heat pumps are found in the + account or if any other exception occurs during the connection process, it logs + an error message and sets the heat pump attribute to None. + + Raises: + Exception: If no heat pumps are found in the account. + """ + if not self.heat_pump: + try: + thermia = Thermia(self.user, self.password) + logger.debug("[ThermiaHeatpump] Connected: %s", str(thermia.connected)) + + if not thermia.heat_pumps: + raise NoHeatPumpsFoundException("No heat pumps found in account") + heat_pump = thermia.heat_pumps[0] + self.heat_pump = heat_pump + logger.debug( + "[ThermiaHeatpump] initialized HeatPump %s", str(self.heat_pump) + ) + logger.debug( + "[ThermiaHeatpump] current supply line temperature: %s", + str(heat_pump.supply_line_temperature), + ) + except NoHeatPumpsFoundException as e: + logger.error("[ThermiaHeatpump] No heat pumps found: %s", e) + self.heat_pump = None + except ConnectionError as e: + logger.error("[ThermiaHeatpump] Connection error: %s", e) + self.heat_pump = None + except Exception as e: # pylint: disable=broad-except + logger.error("[ThermiaHeatpump] Unexpected error: %s", e) + self.heat_pump = None + + # Start API functions + # MQTT publishes all internal values. + # + # Topic is: base_topic + '/heatpumps/0/' + # + def activate_mqtt(self, mqtt_api: MqttApi): + """ + Activate MQTT and publish internal values. + + Args: + api_mqtt_api (mqtt_api.MqttApi): The MQTT API client to use for publishing values. + """ + + self.mqtt_client = mqtt_api + logger.info("[ThermiaHeatpump] Activating MQTT") + logger.debug("[ThermiaHeatpump] MQTT topic: %s", self._get_mqtt_topic()) + + def refresh_api_values(self): + """ + Refresh API values and publish them to MQTT. + + This method ensures the connection to the heat pump, updates the heat pump data, + and publishes the updated values to the MQTT client. It also publishes the configuration + values to the MQTT client. + + Args: + None + + Returns: + None + """ + + logger.debug("[ThermiaHeatpump] Refreshing API values") + self.__ensure_connection() + + if self.mqtt_client and self.heat_pump: + try: + self.heat_pump.update_data() + self.mqtt_client.generic_publish( + self._get_mqtt_topic() + "xx_supply_line_temperature", + self.heat_pump.supply_line_temperature, + ) + for name, value in self._get_all_properties(self.heat_pump): + # Ensure the value is a supported type + if not isinstance(value, (str, bytearray, int, float, type(None))): + value = str(value) + self.mqtt_client.generic_publish( + self._get_mqtt_topic() + name, value + ) + logger.debug("[ThermiaHeatpump] API values refreshed") + + # Publish all config values with config/ prefix + config_topic_prefix = self._get_mqtt_topic() + "config/" + self.mqtt_client.generic_publish( + config_topic_prefix + "min_price_for_evu_block", + self.min_price_for_evu_block, + ) + self.mqtt_client.generic_publish( + config_topic_prefix + "max_evu_block_hours", + self.max_evu_block_hours, + ) + self.mqtt_client.generic_publish( + config_topic_prefix + "max_evu_block_duration", + self.max_evu_block_duration, + ) + self.mqtt_client.generic_publish( + config_topic_prefix + "min_price_for_hot_water_block", + self.min_price_for_hot_water_block, + ) + self.mqtt_client.generic_publish( + config_topic_prefix + "max_hot_water_block_hours", + self.max_hot_water_block_hours, + ) + self.mqtt_client.generic_publish( + config_topic_prefix + "max_hot_water_block_duration", + self.max_hot_water_block_duration, + ) + self.mqtt_client.generic_publish( + config_topic_prefix + "min_price_for_reduced_heat", + self.min_price_for_reduced_heat, + ) + self.mqtt_client.generic_publish( + config_topic_prefix + "max_reduced_heat_hours", + self.max_reduced_heat_hours, + ) + self.mqtt_client.generic_publish( + config_topic_prefix + "reduced_heat_temperature", + self.reduced_heat_temperature, + ) + self.mqtt_client.generic_publish( + config_topic_prefix + "max_price_for_increased_heat", + self.max_price_for_increased_heat, + ) + self.mqtt_client.generic_publish( + config_topic_prefix + "min_energy_surplus_for_increased_heat", + self.min_energy_surplus_for_increased_heat, + ) + self.mqtt_client.generic_publish( + config_topic_prefix + "max_increased_heat_hours", + self.max_increased_heat_hours, + ) + self.mqtt_client.generic_publish( + config_topic_prefix + "max_increased_heat_duration", + self.max_increased_heat_duration, + ) + self.mqtt_client.generic_publish( + config_topic_prefix + "increased_heat_temperature", + self.increased_heat_temperature, + ) + self.mqtt_client.generic_publish( + config_topic_prefix + "max_increased_heat_outdoor_temperature", + self.max_increased_heat_outdoor_temperature, + ) + self.mqtt_client.generic_publish( + config_topic_prefix + "min_energy_surplus_for_hot_water_boost", + self.min_energy_surplus_for_hot_water_boost, + ) + self.mqtt_client.generic_publish( + config_topic_prefix + "max_hot_water_boost_hours", + self.max_hot_water_boost_hours, + ) + + logger.debug("[ThermiaHeatpump] config values published to MQTT ...") + + except Exception as e: # pylint: disable=broad-except + logger.error("[ThermiaHeatpump] Failed to refresh API values: %s", e) + + def _get_all_properties(self, obj): + for name, method in inspect.getmembers( # pylint: disable=unused-variable + obj.__class__, lambda m: isinstance(m, property) + ): + yield name, getattr(obj, name) + + def set_heatpump_parameters(self, net_consumption: np.ndarray, prices: dict): + """ + Set the parameters for the heat pump based on net energy consumption and energy prices. + Parameters: + ----------- + net_consumption : np.ndarray + An array representing the net energy consumption for each hour. + prices : dict + A dictionary where keys are hours and values are the corresponding energy prices. + Returns: + -------- + None + Notes: + ------ + This method determines the operating mode of the heat pump for each hour + based on the net energy consumption and energy prices. + The modes are: + - "W": Hot water boost + - "H": Heat increased temperature + - "N": Heat normal + - "R": Heat reduced temperature + - "B": Hot water block + - "E": EVU Block + The method logs the decision-making process and applies the determined + modes over continuous time windows. + """ + # ensure availability of data + max_hour = min(len(net_consumption), len(prices)) + + duration = datetime.timedelta( + hours=max_hour + ) # add one hour to include the druartion of evenan single 1-hour slot + + curr_hour_start = ( + datetime.datetime.now() + .astimezone(self.batcontrol_timezone) + .replace(minute=0, second=0, microsecond=0) + ) + + max_timestamp = curr_hour_start + duration + if self.heat_pump is not None and max_timestamp > self.already_planned_until: + logger.debug("[ThermiaHeatpump] Planning until %s", max_timestamp) + ## for now we do a full replan + self.high_price_strategies = {} + for start_time, handler in self.high_price_handlers.items(): + self.heat_pump.delete_schedule(handler.schedule) + logger.debug( + "[ThermiaHeatpump] Replan from scratch: Deleted High Price Handler %s", + handler.schedule, + ) + self.high_price_handlers = {} + # fetch the current state of the heat pump and + # calendars and check if they are still valid + # + # - if expired, delete them + # - also delete all schedules tat are in the time window we consider in this planning + + current_schedules = self.heat_pump.get_schedules() + for schedule in current_schedules: + if ( + schedule.start.timestamp() >= curr_hour_start.timestamp() + and schedule.start.timestamp() <= max_timestamp.timestamp() + ): + self.heat_pump.delete_schedule(schedule) + logger.debug( + "[ThermiaHeatpump] Replan from scratch: Deleted conflicting schedule %s", + schedule, + ) + elif schedule.end.timestamp() < curr_hour_start.timestamp(): + self.heat_pump.delete_schedule(schedule) + logger.debug( + "[ThermiaHeatpump] Replan from scratch: Deleted expired schedule %s", + schedule, + ) + else: + logger.debug( + "[ThermiaHeatpump] Replan from scratch: Keeping schedule %s", + schedule, + ) + + # TODO: summer mode would try to match heat pump energy demand and PV surplus + # assumed_hourly_heatpump_energy_demand = 500 # watthour + # assumed_hotwater_reheat_energy_demand = 1500 # watthour + # assumed_hotwater_boost_energy_demand = 1500 # watthour + + heat_modes = ["N"] * max_hour + + # Sort hours by highest prices descending + sorted_hours_by_price = sorted( + range(max_hour), key=lambda h: prices[h], reverse=True + ) + + ### counters for this evaluation + remaining_evu_block_hours = self.max_evu_block_hours + remaining_hot_water_block_hours = self.max_hot_water_block_hours + remaining_reduced_heat_hours = self.max_reduced_heat_hours + remaining_increased_heat_hours = self.max_increased_heat_hours + remaining_hot_water_boost_hours = self.max_hot_water_boost_hours + + # Iterate over hours sorted by price and set modes + # based on trigger price and max hours per day limits + for h in sorted_hours_by_price: + if ( + net_consumption[h] < -self.min_energy_surplus_for_hot_water_boost + and remaining_hot_water_boost_hours > 0 + ): + heat_modes[h] = "W" + remaining_hot_water_boost_hours -= 1 + logger.debug( + "[ThermiaHeatpump] Set Hot Water Boost at +%dh due to high surplus %s", + h, + net_consumption[h], + ) + elif ( + net_consumption[h] < -self.min_energy_surplus_for_increased_heat + or prices[h] <= self.max_price_for_increased_heat + and remaining_increased_heat_hours > 0 + ): + if ( + self.heat_pump.outdoor_temperature + < self.max_increased_heat_outdoor_temperature + ): + heat_modes[h] = "H" + remaining_increased_heat_hours -= 1 + if prices[h] <= self.max_price_for_increased_heat: + logger.debug( + "[ThermiaHeatpump] Set Increased Heat at +%dh due to low price " + + "%s and low outdoor temperature %s", + h, + prices[h], + self.heat_pump.outdoor_temperature, + ) + else: + logger.debug( + "[ThermiaHeatpump] Set Increased Heat at +%dh due to high surplus " + + "%s and low outdoor temperature %s", + h, + net_consumption[h], + self.heat_pump.outdoor_temperature, + ) + else: + heat_modes[h] = "N" + logger.debug( + "[ThermiaHeatpump] Set Normal Heat at +%dh due to high surplus " + + "%s and high outdoor temperature %s", + h, + net_consumption[h], + self.heat_pump.outdoor_temperature, + ) + elif ( + prices[h] >= self.min_price_for_evu_block + and remaining_evu_block_hours > 0 + ): + heat_modes[h] = "E" + remaining_evu_block_hours -= 1 + logger.debug( + "[ThermiaHeatpump] Set EVU Block at +%dh due to high price %s", + h, + prices[h], + ) + elif ( + prices[h] >= self.min_price_for_hot_water_block + and remaining_hot_water_block_hours > 0 + ): + heat_modes[h] = "B" + remaining_hot_water_block_hours -= 1 + logger.debug( + "[ThermiaHeatpump] Set Hot Water Block at +%dh due to high price %s", + h, + prices[h], + ) + elif ( + prices[h] >= self.min_price_for_reduced_heat + and remaining_reduced_heat_hours > 0 + ): + heat_modes[h] = "R" + remaining_reduced_heat_hours -= 1 + logger.debug( + "[ThermiaHeatpump] Set Reduced Heat at +%dh due to high price %s", + h, + prices[h], + ) + else: + heat_modes[h] = "N" + logger.debug( + "[ThermiaHeatpump] Set Normal Heat at +%dh due to price %s", + h, + prices[h], + ) + + # Evaluate the duration of each mode and downgrade to lower mode if necessary + self.adjust_mode_duration( + heat_modes, prices, "E", "B", self.max_evu_block_duration + ) + self.adjust_mode_duration( + heat_modes, prices, "B", "R", self.max_hot_water_block_duration + ) + self.adjust_mode_duration( + heat_modes, prices, "R", "N", self.max_reduced_heat_duration + ) + self.adjust_mode_duration( + heat_modes, prices, "H", "N", self.max_increased_heat_duration + ) + + logger.debug("[ThermiaHeatpump] Adjusted Heatpump Modes: %s", heat_modes) + + # Iterate over heat modes and handle windows of equal mode + start_index = 0 + current_mode = heat_modes[0] + + # -------- here we start to convert indices into timestamps + + for i in range(1, max_hour): + if heat_modes[i] != current_mode: + # Handle the range from start_index to i-1 + self.apply_mode(current_mode, start_index, i - 1) + start_index = i + current_mode = heat_modes[i] + # Handle the last range + self.apply_mode(current_mode, start_index, max_hour) + + for i in range(max_hour): + hours_until_range_start = datetime.timedelta(hours=i) + range_duration = datetime.timedelta( + hours=1 + ) # add one hour to include the duration of evenan single 1-hour slot + + curr_hour_start = ( + datetime.datetime.now() + .astimezone(self.batcontrol_timezone) + .replace(minute=0, second=0, microsecond=0) + ) + start_time = curr_hour_start + hours_until_range_start + end_time = start_time + range_duration + + self.high_price_strategies[start_time] = ThermiaStrategySlot( + start_time, end_time, heat_modes[i], prices[i], net_consumption[i] + ) + + self.cleanup_high_price_strategies() + + self.already_planned_until = max_timestamp + self.publish_strategies_to_mqtt() + + else: + logger.debug( + "[ThermiaHeatpump] No replanning necessary, already planned until %s", + self.already_planned_until, + ) + return + + def adjust_mode_duration( + self, + heat_modes: list[str], + prices: list[float], + inspected_mode: str, + downgrade_mode: str, + max_mode_duration: int, + ): + """ + Adjust the duration of a specific heat mode and downgrade + it if it exceeds the maximum allowed duration. + + Parameters: + ----------- + heat_modes : list + List of heat modes for each hour. + prices : dict + Dictionary of energy prices for each hour. + inspected_mode : str + The heat mode to inspect and potentially downgrade. + downgrade_mode : str + The heat mode to downgrade to if the inspected mode exceeds the maximum duration. + max_mode_duration : int + The maximum allowed duration for the inspected mode. + + Returns: + -------- + None + """ + mode_duration = 0 + start_index = -1 + + for h, mode in enumerate(heat_modes): + if mode == inspected_mode: + if start_index == -1: + start_index = h + mode_duration += 1 + + if mode_duration > max_mode_duration: + if prices[start_index] <= prices[h]: + heat_modes[start_index] = downgrade_mode + logger.debug( + "[ThermiaHeatpump] Downgrade %s to %s at +%dh due to duration limit", + inspected_mode, + downgrade_mode, + start_index, + ) + start_index += 1 + else: + heat_modes[h] = downgrade_mode + logger.debug( + "[ThermiaHeatpump] Downgrade %s to %s at +%dh due to duration limit", + inspected_mode, + downgrade_mode, + h, + ) + mode_duration = 0 + start_index = -1 + else: + mode_duration = 0 + start_index = -1 + + def apply_mode(self, mode: str, start_index: int, end_index: int): + """ + Apply a specific mode to the heat pump for a given time range. + + Args: + mode (str): The mode to be applied to the heat pump. + start_index (int): The starting hour index from the current time. + end_index (int): The ending hour index from the current time. + + Returns: + None + + Logs: + Logs the mode application with the start and end hour indices. + """ + logger.debug( + "[ThermiaHeatpump] Apply Mode %s from +%dh to +%dh", + mode, + start_index, + end_index, + ) + + hours_until_range_start = datetime.timedelta(hours=start_index) + range_duration = datetime.timedelta( + hours=end_index - start_index + 1 + ) # add one hour to include the druartion of evenan single 1-hour slot + + curr_hour_start = ( + datetime.datetime.now() + .astimezone(self.batcontrol_timezone) + .replace(minute=0, second=0, microsecond=0) + ) + range_start_time = curr_hour_start + hours_until_range_start + range_end_time = range_start_time + range_duration + + self.ensure_strategy_for_time_window(range_start_time, range_end_time, mode) + + def ensure_strategy_for_time_window( + self, start_time: datetime, end_time: datetime, mode: str + ): + """ + check whether strategy for certain + time window is already present or install if it is missing + + Args: + start_time (datetime): The start time of the high price window. + end_time (datetime): The end time of the high price window. + mode (str): The mode of operation for the heat pump during the high price window. + Raises: + Error: If the method is not implemented by the subclass. + """ + ## round to full hour + start_time = start_time.replace(minute=0, second=0, microsecond=0) + end_time = end_time.replace(minute=0, second=0, microsecond=0) + + # Adjust start and end times for time zone of heatpump + tz_name = self.heat_pump.installation_timezone + start_time = utils.adjust_times_for_timezone(start_time, tz_name) + end_time = utils.adjust_times_for_timezone(end_time, tz_name) + + duration = end_time - start_time + logger.info( + "[ThermiaHeatpump] Planning Strategy [%s] starting at %s, duration: %s", + mode, + start_time, + duration, + ) + + # Check if a strategy already exists for the given start time + if start_time in self.high_price_handlers: + existing_strategy = self.high_price_handlers[start_time] + logger.info( + "[ThermiaHeatpump] price handler already exists for start time %s: %s", + start_time, + existing_strategy, + ) + return + + schedule = self.install_schedule_in_heatpump(start_time, end_time, mode) + if schedule: + high_price_strategy = ThermiaHighPriceHandling( + start_time, end_time, schedule + ) + logger.info( + "[ThermiaHeatpump] Created high price handler: %s", high_price_strategy + ) + self.high_price_handlers[start_time] = high_price_strategy + self.cleanup_high_price_handlers() + + def install_schedule_in_heatpump( + self, start_time: datetime, end_time: datetime, mode: str + ): + """ + Installs a schedule in the heat pump based on the provided start time, end time, and mode. + + Args: + start_time (datetime): The start time for the schedule. + end_time (datetime): The end time for the schedule. + mode (str): The mode for the schedule. Can be one of the following: + - "E": EVU mode + - "B": Hot water block + - "R": Reduced heating effect + - "H": Increased heating effect + + Returns: + CalendarSchedule: The newly created schedule. + + Raises: + ValueError: If an unknown mode is provided. + """ + + start_str = start_time.astimezone(self.batcontrol_timezone).strftime("%H:%M") + end_str = end_time.astimezone(self.batcontrol_timezone).strftime("%H:%M") + + if mode == "E": + planned_schedule = CalendarSchedule( + start=start_time, end=end_time, functionId=CAL_FUNCTION_EVU_MODE + ) + schedule = self.heat_pump.add_new_schedule(planned_schedule) + logger.debug( + "[ThermiaHeatpump] Set Heatpump to EVU block from %s to %s", + start_str, + end_str, + ) + return schedule + elif mode == "B": + planned_schedule = CalendarSchedule( + start=start_time, end=end_time, functionId=CAL_FUNCTION_HOT_WATER_BLOCK + ) + schedule = self.heat_pump.add_new_schedule(planned_schedule) + logger.debug( + "[ThermiaHeatpump] Set Heatpump to Hot water BLOCK from %s to %s", + start_str, + end_str, + ) + return schedule + elif mode == "R": + planned_schedule = CalendarSchedule( + start=start_time, + end=end_time, + functionId=CAL_FUNCTION_REDUCED_HEATING_EFFECT, + value=self.reduced_heat_temperature, + ) + schedule = self.heat_pump.add_new_schedule(planned_schedule) + logger.debug( + "[ThermiaHeatpump] Set Heatpump to REDUCED Heating (%s) from %s to %s", + self.reduced_heat_temperature, + start_str, + end_str, + ) + return schedule + elif mode == "H": + planned_schedule = CalendarSchedule( + start=start_time, + end=end_time, + functionId=CAL_FUNCTION_REDUCED_HEATING_EFFECT, + value=self.increased_heat_temperature, + ) + schedule = self.heat_pump.add_new_schedule(planned_schedule) + logger.debug( + "[ThermiaHeatpump] Set Heatpump to INCREASED Heating (%s) from %s to %s", + self.increased_heat_temperature, + start_str, + end_str, + ) + return schedule + elif mode == "W": + logger.debug( + "[ThermiaHeatpump] TODO No impl for Heatpump to Hot Water BOOST from %s to %s", + start_str, + end_str, + ) + return + elif mode == "N": + logger.debug( + "[ThermiaHeatpump] No change in Heatpump mode from %s to %s", + start_str, + end_str, + ) + return + else: + logger.error("[ThermiaHeatpump] Unknown mode: %s", mode) + raise ValueError(f"Unknown mode: {mode}") + + def cleanup_high_price_strategies(self): + """ + Remove all high price strategies that are no longer valid. + + This method iterates through the high price strategies stored in the + `self.high_price_strategies` dictionary and removes any strategies + whose end time is before the current time. The current time is + adjusted to the heat pump's installation timezone. + The method logs the following information: + - The number of high price strategies before cleanup. + - Each strategy that is removed, including its start and end times. + - The number of remaining high price strategies after cleanup. + + """ + logger.debug( + "[ThermiaHeatpump] Cleaning up high price strategies, currently %d strategies", + len(self.high_price_strategies), + ) + now = datetime.datetime.now( + self.batcontrol_timezone + ) # Make 'now' an aware datetime object in the heat pump's timezone + now = utils.adjust_times_for_timezone(now, self.heat_pump.installation_timezone) + + strategies_to_remove = [] + + for start_time, strategy in self.high_price_strategies.items(): + if strategy.end_time.timestamp() < now.timestamp(): + logger.debug( + "[ThermiaHeatpump] Removing high price strategy at %s - %s, " + + "because it ends before now: %s", + start_time, + strategy.end_time, + now, + ) + strategies_to_remove.append(start_time) + + for start_time in strategies_to_remove: + del self.high_price_strategies[start_time] + logger.debug( + "[ThermiaHeatpump] Removed high price strategy for %s", start_time + ) + logger.debug( + "[ThermiaHeatpump] Cleanup complete. Remaining strategies: %d", + len(self.high_price_strategies), + ) + + def cleanup_high_price_handlers(self): + """ + Remove all high price handlers that are no longer valid. + """ + now = datetime.datetime.now( + self.batcontrol_timezone + ) # Make 'now' an aware datetime object in the heat pump's timezone + now = utils.adjust_times_for_timezone(now, self.heat_pump.installation_timezone) + handlers_to_remove = [] + + for start_time, handler in self.high_price_handlers.items(): + end_time = handler.end_time + if end_time.timestamp() < now.timestamp(): + logger.debug( + "[ThermiaHeatpump] Removing high price handler for %s-%s , " + + "because it ends before now: %s", + start_time, + end_time, + now, + ) + handlers_to_remove.append(start_time) + + for start_time in handlers_to_remove: + del self.high_price_handlers[start_time] + logger.debug( + "[ThermiaHeatpump] Removed high price handler for %s", start_time + ) + logger.debug( + "[ThermiaHeatpump] Cleanup complete. Remaining handlers: %d", + len(self.high_price_handlers), + ) + + def publish_strategies_to_mqtt(self): + """ + Publishes high price handlers and strategies to MQTT. + + This method performs the following steps: + 1. Logs the number of handlers and strategies to be published. + 2. Deletes all existing high price handlers from the MQTT topics. + 3. Publishes each high price handler to its respective MQTT topic. + 4. Deletes all existing high price strategies from the MQTT topics. + 5. Publishes each high price strategy to its respective MQTT topic. + + Each handler and strategy is published with its associated details such as + start time, end time, mode, price, consumption, and handler function ID. + + Note: + This method assumes that `self.mqtt_client` is an instance of an MQTT client + that has methods `delete_all_topics` and `generic_publish`. + + Raises: + AttributeError: If `self.mqtt_client` is None. + + """ + if self.mqtt_client: + # Delete all existing high price handlers + logger.debug( + "[ThermiaHeatpump] publishing strategy values (%d handlers, %d strategies) to MQTT", + len(self.high_price_handlers), + len(self.high_price_strategies), + ) + + handlers_prefix = self._get_mqtt_topic() + "handlers/" + + logger.debug( + "[ThermiaHeatpump] Cleaning up all previously published handlers at %s", + handlers_prefix, + ) + self.mqtt_client.delete_all_topics(handlers_prefix) + + for index, (start_time, handler) in enumerate( + self.high_price_handlers.items() + ): + mqtt_handler_topic = handlers_prefix + str(index) + self.mqtt_client.generic_publish( + mqtt_handler_topic, + start_time.strftime("%Y-%m-%d_%H:%M") + + "-" + + handler.end_time.strftime("%H:%M") + + "-" + + str(handler.schedule.functionId), + ) + self.mqtt_client.generic_publish( + mqtt_handler_topic + "/start_time", + handler.start_time.strftime("%Y-%m-%d %H:%M"), + ) + self.mqtt_client.generic_publish( + mqtt_handler_topic + "/end_time", + handler.end_time.strftime("%Y-%m-%d %H:%M"), + ) + self.mqtt_client.generic_publish( + mqtt_handler_topic + "/calendar_function_id", + handler.schedule.functionId, + ) + + # Delete all existing high price strategies + strategies_prefix = self._get_mqtt_topic() + "strategies/" + logger.debug( + "[ThermiaHeatpump] Cleaning up all previously published strategies at %s", + strategies_prefix, + ) + self.mqtt_client.delete_all_topics(strategies_prefix) + + for index, (start_time, strategy) in enumerate( + self.high_price_strategies.items() + ): + high_price_strategy_topic = strategies_prefix + str(index) + self.mqtt_client.generic_publish( + high_price_strategy_topic, + start_time.strftime("%Y-%m-%d %H:%M") + ":" + strategy.mode, + ) + self.mqtt_client.generic_publish( + high_price_strategy_topic + "/price", strategy.price + ) + self.mqtt_client.generic_publish( + high_price_strategy_topic + "/consumption", strategy.consumption + ) + self.mqtt_client.generic_publish( + high_price_strategy_topic + "/mode", strategy.mode + ) + self.mqtt_client.generic_publish( + high_price_strategy_topic + "/start_time", + strategy.start_time.strftime("%Y-%m-%d %H:%M"), + ) + self.mqtt_client.generic_publish( + high_price_strategy_topic + "/end_time", + strategy.end_time.strftime("%Y-%m-%d %H:%M"), + ) + if strategy.handler: + self.mqtt_client.generic_publish( + high_price_strategy_topic + "/handler", + strategy.handler.schedule.functionId, + ) + + logger.debug( + "[ThermiaHeatpump] strategy values (%d handlers, %d strategies) published to MQTT", + len(self.high_price_handlers), + len(self.high_price_strategies), + ) + + def shutdown(self): + """ + Destructor to clean up high price handlers + and delete corresponding schedules in the Thermia API. + """ + logger.debug("[ThermiaHeatpump Destructor] Cleaning up high price handlers") + if self.heat_pump: + for start_time, handler in self.high_price_handlers.items(): + try: + self.heat_pump.delete_schedule(handler.schedule) + logger.info( + "[ThermiaHeatpump] Deleted schedule for high price handler starting at %s", + start_time, + ) + except Exception as e: # pylint: disable=broad-except + logger.error( + "[ThermiaHeatpump] Failed to delete schedule for handler at %s: %s", + start_time, + e, + ) diff --git a/mqtt_api.py b/mqtt_api.py index bb745fb2..ae83fbb2 100644 --- a/mqtt_api.py +++ b/mqtt_api.py @@ -344,3 +344,41 @@ def generic_publish(self, topic:str, value:str) -> None: """ if self.client.is_connected(): self.client.publish(self.base_topic + '/' + topic, value) + + + def delete_all_topics(self, prefix: str) -> None: + """ + Deletes all MQTT topics with the given prefix. + This method constructs a full topic prefix by combining the base topic and the provided prefix. + It then subscribes to all topics matching this prefix and publishes a message with a `None` payload + and `retain=True` to delete each topic. + Args: + prefix (str): The prefix of the topics to delete. + Returns: + None + """ + if prefix.endswith('/'): + prefix = prefix[:-1] + + f_q_prefix=self.base_topic + '/' + prefix + logger.debug('[MQTT] Deleting all topics with prefix %s', f_q_prefix) + if self.client.is_connected(): + def on_message_delete(client, userdata, message): # pylint: disable=unused-argument # callback + logger.info('[MQTT] Deleting topic %s', message.topic) + self.client.publish(message.topic, None, retain=True) + + topic_wildcard = f_q_prefix + '/#' + self.client.message_callback_add(topic_wildcard, on_message_delete) + self.client.subscribe(topic_wildcard) + logger.debug('[MQTT] Waiting for messages matching topic (%s)', topic_wildcard) + + # Wait for all messages to be processed + # from current observation, 3 seconds seem enough + time.sleep(3) + + self.client.unsubscribe(topic_wildcard) + self.client.message_callback_remove(topic_wildcard) + logger.debug('[MQTT] All topics with prefix %s deleted', topic_wildcard) + return + + \ No newline at end of file diff --git a/thermia_online_api b/thermia_online_api new file mode 160000 index 00000000..56c8a124 --- /dev/null +++ b/thermia_online_api @@ -0,0 +1 @@ +Subproject commit 56c8a12420fe9ef70a2be2096f3d0d4f78626dcc From bbb9b89aca2b9a02c134485216873ed8924e0201 Mon Sep 17 00:00:00 2001 From: hashtagKnorke <61380298+hashtagKnorke@users.noreply.github.com> Date: Sun, 29 Dec 2024 16:39:16 +0000 Subject: [PATCH 02/10] fix: format MQTT handler and strategy topics with zero-padded indices for ASCII sorting properly --- heatpump/thermia_heatpump.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/heatpump/thermia_heatpump.py b/heatpump/thermia_heatpump.py index 322737b5..a1b2077c 100644 --- a/heatpump/thermia_heatpump.py +++ b/heatpump/thermia_heatpump.py @@ -1191,7 +1191,7 @@ def publish_strategies_to_mqtt(self): for index, (start_time, handler) in enumerate( self.high_price_handlers.items() ): - mqtt_handler_topic = handlers_prefix + str(index) + mqtt_handler_topic = handlers_prefix + f"{index:02d}" self.mqtt_client.generic_publish( mqtt_handler_topic, start_time.strftime("%Y-%m-%d_%H:%M") @@ -1224,7 +1224,7 @@ def publish_strategies_to_mqtt(self): for index, (start_time, strategy) in enumerate( self.high_price_strategies.items() ): - high_price_strategy_topic = strategies_prefix + str(index) + high_price_strategy_topic = strategies_prefix + f"{index:02d}" self.mqtt_client.generic_publish( high_price_strategy_topic, start_time.strftime("%Y-%m-%d %H:%M") + ":" + strategy.mode, From 01d3c2ffc593c1b962a787d2701562d84ccf3f26 Mon Sep 17 00:00:00 2001 From: hashtagKnorke <61380298+hashtagKnorke@users.noreply.github.com> Date: Thu, 2 Jan 2025 14:15:52 +0100 Subject: [PATCH 03/10] Refactor pylint workflow to analyze only changed Python files (#102) * Refactor pylint workflow to analyze only changed Python files * Add step to fetch main branch for git diff comparison in pylint workflow * Enhance pylint workflow to dynamically determine and fetch the base branch (origin/main vs. upstream/main) for comparison * fix indentation error * Add check to prevent duplicate upstream remote addition in pylint workflow --- .github/workflows/pylint.yml | 34 +++++++++++++++++++++++++++++++--- 1 file changed, 31 insertions(+), 3 deletions(-) diff --git a/.github/workflows/pylint.yml b/.github/workflows/pylint.yml index 2e21e70f..909a179e 100644 --- a/.github/workflows/pylint.yml +++ b/.github/workflows/pylint.yml @@ -1,6 +1,6 @@ name: Pylint -on: +on: workflow_dispatch: push: branches: @@ -13,7 +13,7 @@ on: jobs: - build: + lint: runs-on: self-hosted #runs-on: ubuntu-latest strategy: @@ -21,14 +21,42 @@ jobs: python-version: ["3.8", "3.9", "3.10"] steps: - uses: actions/checkout@v4 + with: + fetch-depth: 0 # Fetch all history for all branches + - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v3 with: python-version: ${{ matrix.python-version }} + - name: Install dependencies run: | python -m pip install --upgrade pip pip install pylint + + - name: Determine base branch and fetch it + run: | + if [ "${{ github.event.pull_request.head.repo.full_name }}" == "${{ github.repository }}" ]; then + BASE_BRANCH="origin/main" + git fetch origin main + else + BASE_BRANCH="upstream/main" + if ! git remote | grep upstream; then + git remote add upstream https://github.com/${{ github.repository_owner }}/${{ github.event.repository.name }}.git + fi + git fetch upstream main + fi + echo "BASE_BRANCH=$BASE_BRANCH" >> $GITHUB_ENV + + - name: Get changed files + id: changed-files + run: | + CHANGED_FILES=$(git diff --name-only $BASE_BRANCH...HEAD -- '*.py') + echo "CHANGED_FILES=$CHANGED_FILES" >> $GITHUB_ENV + - name: Analysing the code with pylint run: | - pylint $(git ls-files '*.py') + for file in ${{ env.CHANGED_FILES }}; do + pylint "$file" + done + From d07c8df1decc8b11d5033e193c10c5d38a7380b7 Mon Sep 17 00:00:00 2001 From: hashtagKnorke <61380298+hashtagKnorke@users.noreply.github.com> Date: Thu, 2 Jan 2025 14:12:14 +0000 Subject: [PATCH 04/10] Set default timezone to UTC in Dockerfile and output timezone in entrypoint script add tzdata package --- Dockerfile | 7 ++++++- entrypoint.sh | 4 ++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index dbf0f58c..b48d5b4a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,6 +10,10 @@ LABEL maintainer="matthias.strubel@aod-rpg.de" ENV BATCONTROL_VERSION=${VERSION} ENV BATCONTROL_GIT_SHA=${GIT_SHA} +# Set default timezone to UTC, override with -e TZ=Europe/Berlin or similar +# when starting the container +# or set the timezone in docker-compose.yml in the environment section, +ENV TZ=UTC RUN mkdir -p /app /app/logs /app/config WORKDIR /app @@ -19,7 +23,8 @@ RUN apk add --no-cache \ py3-pandas\ py3-yaml\ py3-requests\ - py3-paho-mqtt + py3-paho-mqtt \ + tzdata COPY *.py ./ diff --git a/entrypoint.sh b/entrypoint.sh index a11eb5f3..20ff67d1 100644 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -36,5 +36,9 @@ if test ! -e "/app/logs/batcontrol.log" ; then ln -s /app/logs/batcontrol.log /app/batcontrol.log fi +# Output the timezone +echo "Current local time is: $(date)" +echo "Configured timezone (env var TZ) is: $TZ" + # Start batcontrol.py exec python /app/batcontrol.py From c9334309b754a3d4b275a9329e763a6c3aefb976 Mon Sep 17 00:00:00 2001 From: hashtagKnorke <61380298+hashtagKnorke@users.noreply.github.com> Date: Thu, 2 Jan 2025 14:31:26 +0000 Subject: [PATCH 05/10] Add timezone adjustment instructions to README --- README.MD | 34 +++++++++++++++++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/README.MD b/README.MD index 07457910..12016808 100644 --- a/README.MD +++ b/README.MD @@ -52,7 +52,6 @@ mkdir -p ./config -p ./logs - Download the the latest [batcontrol_config.yaml](https://raw.githubusercontent.com/muexxl/batcontrol/refs/heads/main/config/batcontrol_config_dummy.yaml) sample, adjust and place it to config/batcontrol_config.yaml. - Use the default load_profile (automatically) or create your own.- - ### Plain Docker ``` @@ -81,6 +80,39 @@ services: Then start the container using `docker-compose up -d`. +### Adjusting Timezone + +To adjust the timezone for logging and output, set the `TZ` environment variable. + +#### Plain Docker + +``` +docker run -d \ + --name batcontrol \ + -v /path/to/config:/app/config \ + -v /path/to/logs:/app/logs \ + -e TZ=Europe/Berlin \ + muexx/batcontrol:latest +``` + +#### Docker-compose example + +Add the `TZ` environment variable to your `docker-compose.yml`: + +``` +version: '3.8' + +services: + batcontrol: + image: muexx/batcontrol:latest + volumes: + - ./config:/app/config + - ./logs:/app/logs + environment: + - TZ=Europe/Berlin + restart: unless-stopped +``` + # FAQs ## How are the different config parameters related to each other? From 7c5fcc598fb7b5079e990f9294da6531c9fdad82 Mon Sep 17 00:00:00 2001 From: hashtagKnorke <61380298+hashtagKnorke@users.noreply.github.com> Date: Fri, 3 Jan 2025 09:17:18 +0000 Subject: [PATCH 06/10] Proper log level and messages for mqtt deletion Fixes hashtagKnorke/batcontrol#28 --- heatpump/thermia_heatpump.py | 23 +++++++++++++---------- mqtt_api.py | 2 +- 2 files changed, 14 insertions(+), 11 deletions(-) diff --git a/heatpump/thermia_heatpump.py b/heatpump/thermia_heatpump.py index a1b2077c..a2a7b011 100644 --- a/heatpump/thermia_heatpump.py +++ b/heatpump/thermia_heatpump.py @@ -34,11 +34,15 @@ logger.info("[Heatpump] loading module ") #### hack to add the submodule to the python path before import fails -if os.path.isdir("thermia_online_api") and os.path.exists("thermia_online_api/ThermiaOnlineAPI"): +if os.path.isdir("thermia_online_api") and os.path.exists( + "thermia_online_api/ThermiaOnlineAPI" +): sys.path.append(os.path.abspath("thermia_online_api")) - logger.warning("ThermiaOnlineAPI module added to Python path from 'thermia_online_api' " - +"subdirectory. This is a hack because of the forked library being " - +"integrated as submodule in a subdir instead of root dir.") + logger.warning( + "ThermiaOnlineAPI module added to Python path from 'thermia_online_api' " + + "subdirectory. This is a hack because of the forked library being " + + "integrated as submodule in a subdir instead of root dir." + ) from ThermiaOnlineAPI.const import ( CAL_FUNCTION_EVU_MODE, @@ -54,7 +58,6 @@ from .baseclass import HeatpumpBaseclass, NoHeatPumpsFoundException - @dataclass class ThermiaHighPriceHandling: """ @@ -161,8 +164,6 @@ def __repr__(self): return f"STRATEGY({self.start_time}-{self.end_time}:[{self.mode}])" - - class ThermiaHeatpump( HeatpumpBaseclass ): # pylint: disable=too-many-instance-attributes @@ -570,7 +571,7 @@ def refresh_api_values(self): logger.error("[ThermiaHeatpump] Failed to refresh API values: %s", e) def _get_all_properties(self, obj): - for name, method in inspect.getmembers( # pylint: disable=unused-variable + for name, method in inspect.getmembers( # pylint: disable=unused-variable obj.__class__, lambda m: isinstance(m, property) ): yield name, getattr(obj, name) @@ -1183,7 +1184,8 @@ def publish_strategies_to_mqtt(self): handlers_prefix = self._get_mqtt_topic() + "handlers/" logger.debug( - "[ThermiaHeatpump] Cleaning up all previously published handlers at %s", + "[ThermiaHeatpump] Cleaning up all previously published " + + "MQTT topics for handlers at %s", handlers_prefix, ) self.mqtt_client.delete_all_topics(handlers_prefix) @@ -1216,7 +1218,8 @@ def publish_strategies_to_mqtt(self): # Delete all existing high price strategies strategies_prefix = self._get_mqtt_topic() + "strategies/" logger.debug( - "[ThermiaHeatpump] Cleaning up all previously published strategies at %s", + "[ThermiaHeatpump] Cleaning up all previously published " + + "MQTT topics for strategies at %s", strategies_prefix, ) self.mqtt_client.delete_all_topics(strategies_prefix) diff --git a/mqtt_api.py b/mqtt_api.py index ae83fbb2..11c7aece 100644 --- a/mqtt_api.py +++ b/mqtt_api.py @@ -364,7 +364,7 @@ def delete_all_topics(self, prefix: str) -> None: logger.debug('[MQTT] Deleting all topics with prefix %s', f_q_prefix) if self.client.is_connected(): def on_message_delete(client, userdata, message): # pylint: disable=unused-argument # callback - logger.info('[MQTT] Deleting topic %s', message.topic) + logger.debug('[MQTT] Deleting topic %s', message.topic) self.client.publish(message.topic, None, retain=True) topic_wildcard = f_q_prefix + '/#' From 9f7fa98c8713b9d0a17adc6c0f30bc8c8b948f96 Mon Sep 17 00:00:00 2001 From: hashtagKnorke <61380298+hashtagKnorke@users.noreply.github.com> Date: Fri, 3 Jan 2025 09:21:29 +0000 Subject: [PATCH 07/10] refactor: remove redundant debug log messages in ThermiaHeatpump class fixes: reduce periodic MQTT publish logs hashtagKnorke/batcontrol#25 --- heatpump/thermia_heatpump.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/heatpump/thermia_heatpump.py b/heatpump/thermia_heatpump.py index a2a7b011..806e495a 100644 --- a/heatpump/thermia_heatpump.py +++ b/heatpump/thermia_heatpump.py @@ -475,7 +475,6 @@ def refresh_api_values(self): None """ - logger.debug("[ThermiaHeatpump] Refreshing API values") self.__ensure_connection() if self.mqtt_client and self.heat_pump: @@ -492,7 +491,6 @@ def refresh_api_values(self): self.mqtt_client.generic_publish( self._get_mqtt_topic() + name, value ) - logger.debug("[ThermiaHeatpump] API values refreshed") # Publish all config values with config/ prefix config_topic_prefix = self._get_mqtt_topic() + "config/" @@ -565,7 +563,7 @@ def refresh_api_values(self): self.max_hot_water_boost_hours, ) - logger.debug("[ThermiaHeatpump] config values published to MQTT ...") + logger.debug("[ThermiaHeatpump] values published to MQTT ...") except Exception as e: # pylint: disable=broad-except logger.error("[ThermiaHeatpump] Failed to refresh API values: %s", e) From 15722da782b63432c8e63cff46db110510597e16 Mon Sep 17 00:00:00 2001 From: hashtagKnorke <61380298+hashtagKnorke@users.noreply.github.com> Date: Fri, 3 Jan 2025 09:26:10 +0000 Subject: [PATCH 08/10] refactor: remove redundant debug log messages in ThermiaHeatpump class fixes: reduce periodic handler cleanup logs hashtagKnorke/batcontrol#26 --- heatpump/thermia_heatpump.py | 15 ++------------- 1 file changed, 2 insertions(+), 13 deletions(-) diff --git a/heatpump/thermia_heatpump.py b/heatpump/thermia_heatpump.py index 806e495a..bb515d58 100644 --- a/heatpump/thermia_heatpump.py +++ b/heatpump/thermia_heatpump.py @@ -1085,10 +1085,6 @@ def cleanup_high_price_strategies(self): - The number of remaining high price strategies after cleanup. """ - logger.debug( - "[ThermiaHeatpump] Cleaning up high price strategies, currently %d strategies", - len(self.high_price_strategies), - ) now = datetime.datetime.now( self.batcontrol_timezone ) # Make 'now' an aware datetime object in the heat pump's timezone @@ -1112,10 +1108,7 @@ def cleanup_high_price_strategies(self): logger.debug( "[ThermiaHeatpump] Removed high price strategy for %s", start_time ) - logger.debug( - "[ThermiaHeatpump] Cleanup complete. Remaining strategies: %d", - len(self.high_price_strategies), - ) + def cleanup_high_price_handlers(self): """ @@ -1144,11 +1137,7 @@ def cleanup_high_price_handlers(self): logger.debug( "[ThermiaHeatpump] Removed high price handler for %s", start_time ) - logger.debug( - "[ThermiaHeatpump] Cleanup complete. Remaining handlers: %d", - len(self.high_price_handlers), - ) - + def publish_strategies_to_mqtt(self): """ Publishes high price handlers and strategies to MQTT. From eee047b46b0a6f671c8b545f35dd45b482e05e1f Mon Sep 17 00:00:00 2001 From: hashtagKnorke <61380298+hashtagKnorke@users.noreply.github.com> Date: Fri, 3 Jan 2025 09:37:44 +0000 Subject: [PATCH 09/10] fix: correct range duration calculation in ThermiaHeatpump class (adding +1h in multiple code locations redundantly addfs up to wrong durations), partially fixes 01.01.2025 ..... hashtagKnorke/batcontrol#27 --- heatpump/thermia_heatpump.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/heatpump/thermia_heatpump.py b/heatpump/thermia_heatpump.py index bb515d58..6b02dee2 100644 --- a/heatpump/thermia_heatpump.py +++ b/heatpump/thermia_heatpump.py @@ -911,8 +911,8 @@ def apply_mode(self, mode: str, start_index: int, end_index: int): hours_until_range_start = datetime.timedelta(hours=start_index) range_duration = datetime.timedelta( - hours=end_index - start_index + 1 - ) # add one hour to include the druartion of evenan single 1-hour slot + hours=end_index - start_index + ) curr_hour_start = ( datetime.datetime.now() From 42a94deb7fd07f692a87709bc875c092784853f3 Mon Sep 17 00:00:00 2001 From: hashtagKnorke <61380298+hashtagKnorke@users.noreply.github.com> Date: Fri, 3 Jan 2025 09:49:39 +0000 Subject: [PATCH 10/10] fix: adjust range duration calculation in ThermiaHeatpump class to correctly include single hour slots --- heatpump/thermia_heatpump.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/heatpump/thermia_heatpump.py b/heatpump/thermia_heatpump.py index 6b02dee2..bdf845d4 100644 --- a/heatpump/thermia_heatpump.py +++ b/heatpump/thermia_heatpump.py @@ -793,7 +793,7 @@ def set_heatpump_parameters(self, net_consumption: np.ndarray, prices: dict): start_index = i current_mode = heat_modes[i] # Handle the last range - self.apply_mode(current_mode, start_index, max_hour) + self.apply_mode(current_mode, start_index, max_hour - 1) for i in range(max_hour): hours_until_range_start = datetime.timedelta(hours=i) @@ -911,8 +911,8 @@ def apply_mode(self, mode: str, start_index: int, end_index: int): hours_until_range_start = datetime.timedelta(hours=start_index) range_duration = datetime.timedelta( - hours=end_index - start_index - ) + hours=end_index - start_index + 1 + ) # add one hour to include the duration of even an single 1-hour slot +0:00 - +0:00 curr_hour_start = ( datetime.datetime.now() @@ -1109,7 +1109,6 @@ def cleanup_high_price_strategies(self): "[ThermiaHeatpump] Removed high price strategy for %s", start_time ) - def cleanup_high_price_handlers(self): """ Remove all high price handlers that are no longer valid.