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/.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 + 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..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 ./ @@ -32,6 +37,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..12016808 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 @@ -51,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 ``` @@ -80,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? 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/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 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..bdf845d4 --- /dev/null +++ b/heatpump/thermia_heatpump.py @@ -0,0 +1,1269 @@ +# 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 + """ + + 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 + ) + + # 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] 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 - 1) + + 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 duration of even an single 1-hour slot +0:00 - +0:00 + + 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. + + """ + 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 + ) + + 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 + ) + + 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 " + + "MQTT topics for 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 + f"{index:02d}" + 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 " + + "MQTT topics for 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 + f"{index:02d}" + 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..11c7aece 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.debug('[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