From d1b06fab1e5dc9ee55b138e203933d4efab5a5ac Mon Sep 17 00:00:00 2001 From: ohAnd <15704728+ohAnd@users.noreply.github.com> Date: Sat, 12 Apr 2025 22:03:29 +0200 Subject: [PATCH 1/3] feat: Enhance price management and configuration - Updated CONFIG_README.md to include new configuration options for feed-in price and negative price switch. - Modified config.py to add feed-in price and negative price switch to the price configuration. - Introduced price_interface.py to manage electricity price data retrieval and processing from Akkudoktor and Tibber APIs. - Refactored eos_connect.py to utilize the new PriceInterface for fetching current prices and feed-in prices. - Implemented background update service in battery_interface.py to periodically fetch state of charge (SOC) data. - Updated load_interface.py to improve error handling and data processing. - Commented out debug logs in base_control.py and battery_interface.py to reduce log verbosity. --- src/CONFIG_README.md | 2 + src/config.py | 8 + src/eos_connect.py | 550 ++++++++++------------------ src/interfaces/base_control.py | 2 +- src/interfaces/battery_interface.py | 52 ++- src/interfaces/load_interface.py | 10 +- src/interfaces/price_interface.py | 385 +++++++++++++++++++ 7 files changed, 636 insertions(+), 373 deletions(-) create mode 100644 src/interfaces/price_interface.py diff --git a/src/CONFIG_README.md b/src/CONFIG_README.md index 0eab6663..219074ae 100644 --- a/src/CONFIG_README.md +++ b/src/CONFIG_README.md @@ -23,6 +23,8 @@ A default config file will be created with the first start, if there is no confi - **price.source**: Data source for electricity price. Possible values: `tibber`, `akkudoktor`. - **price.token**: Token for electricity price. +- **price.feed_in_price**: Feed-in price for the grid. +- **price.negative_price_switch**: Switch for handling negative electricity prices (e.g., no payment if negative stock price). ### Battery Configuration - **battery.source**: Data source for battery SOC. Possible values: openhab, homeassistant, default (static data). diff --git a/src/config.py b/src/config.py index 3e335074..b612d947 100644 --- a/src/config.py +++ b/src/config.py @@ -59,6 +59,8 @@ def create_default_config(self): { "source": "default", "token": "tibberBearerToken", # token for electricity price + "feed_in_price": 0.0, # feed in price for the grid + "negative_price_switch": False, # switch for negative price } ), "battery": CommentedMap( @@ -155,6 +157,12 @@ def create_default_config(self): config["price"].yaml_set_comment_before_after_key( "token", before="Token for electricity price" ) + config["price"].yaml_set_comment_before_after_key( + "feed_in_price", before="feed in price for the grid" + ) + config["price"].yaml_set_comment_before_after_key( + "negative_price_switch", before="switch for no payment if negative stock price" + ) # battery configuration config.yaml_set_comment_before_after_key("battery", before="battery configuration") config["battery"].yaml_set_comment_before_after_key( diff --git a/src/eos_connect.py b/src/eos_connect.py index 4d3d80c4..881d86d0 100644 --- a/src/eos_connect.py +++ b/src/eos_connect.py @@ -8,7 +8,7 @@ import time import logging import json -from threading import Thread +import threading import sched import pytz import requests @@ -21,8 +21,10 @@ from interfaces.inverter_fronius import FroniusWR from interfaces.evcc_interface import EvccInterface from interfaces.eos_interface import EosInterface +from interfaces.price_interface import PriceInterface EOS_TGT_DURATION = 48 +EOS_API_GET_PV_FORECAST = "https://api.akkudoktor.net/forecast" ################################################################################################### @@ -120,12 +122,6 @@ def charging_state_callback(new_state): on_charging_state_change=charging_state_callback, ) -# time.sleep(120) - -# evcc_interface.shutdown() - -# sys.exit(0) - # intialize the load interface load_interface = LoadInterface( config_manager.config.get("load", {}).get("source", ""), @@ -143,209 +139,12 @@ def charging_state_callback(new_state): config_manager.config.get("battery", {}).get("access_token", ""), ) -EOS_API_GET_PV_FORECAST = "https://api.akkudoktor.net/forecast" -AKKUDOKTOR_API_PRICES = "https://api.akkudoktor.net/prices" -TIBBER_API = "https://api.tibber.com/v1-beta/gql" - - -# getting data -def get_prices(tgt_duration, start_time=None): - """ - Retrieve prices based on the target duration and optional start time. - - This function fetches prices from different sources based on the configuration. - It supports fetching prices from 'tibber' and 'default' sources. - - Args: - tgt_duration (int): The target duration for which prices are to be fetched. - start_time (datetime, optional): The start time from which prices are to be fetched. - Defaults to None. - - Returns: - list: A list of prices for the specified duration and start time. Returns an empty list - if the price source is not supported. - """ - if config_manager.config["price"]["source"] == "tibber": - return get_prices_from_tibber(tgt_duration, start_time) - if config_manager.config["price"]["source"] == "default": - return get_prices_from_akkudoktor(tgt_duration, start_time) - logger.error("[PRICES] Price source currently not supported.") - return [] - - -def get_prices_from_akkudoktor(tgt_duration, start_time=None): - """ - Fetches and processes electricity prices for today and tomorrow. - - This function retrieves electricity prices for today and tomorrow from an API, - processes the prices, and returns a list of prices for the specified duration starting - from the specified start time. If tomorrow's prices are not available, today's prices are - repeated for tomorrow. - - Args: - tgt_duration (int): The target duration in hours for which the prices are needed. - start_time (datetime, optional): The start time for fetching prices. Defaults to None. - - Returns: - list: A list of electricity prices for the specified duration starting - from the specified start time. - """ - if config_manager.config["price"]["source"] != "default": - logger.error( - "[PRICES] Price source %s currently not supported.", - config_manager.config["price"]["source"], - ) - return [] - logger.debug("[PRICES] Fetching prices from akkudoktor ...") - if start_time is None: - start_time = datetime.now(time_zone).replace(minute=0, second=0, microsecond=0) - current_hour = start_time.hour - request_url = ( - AKKUDOKTOR_API_PRICES - + "?start=" - + start_time.strftime("%Y-%m-%d") - + "&end=" - + (start_time + timedelta(days=1)).strftime("%Y-%m-%d") - ) - logger.debug("[PRICES] Requesting prices from akkudoktor: %s", request_url) - try: - response = requests.get(request_url, timeout=10) - response.raise_for_status() - data = response.json() - except requests.exceptions.Timeout: - logger.error( - "[PRICES] Request timed out while fetching prices from akkudoktor." - ) - return [] - except requests.exceptions.RequestException as e: - logger.error( - "[PRICES] Request failed while fetching prices from akkudoktor: %s", e - ) - return [] - - prices = [] - for price in data["values"]: - prices.append(round(price["marketpriceEurocentPerKWh"] / 100000, 9)) - # logger.debug( - # "[Main] day 1 - price for %s -> %s", price["marketpriceEurocentPerKWh"], - # price["start"] - # ) - - if start_time is None: - start_time = datetime.now(time_zone).replace(minute=0, second=0, microsecond=0) - current_hour = start_time.hour - extended_prices = prices[current_hour : current_hour + tgt_duration] - - if len(extended_prices) < tgt_duration: - remaining_hours = tgt_duration - len(extended_prices) - extended_prices.extend(prices[:remaining_hours]) - logger.info("[PRICES] Prices from AKKUDOKTOR fetched successfully.") - return extended_prices - - -def get_prices_from_tibber(tgt_duration, start_time=None): - """ - Fetches and processes electricity prices for today and tomorrow. - - This function retrieves electricity prices for today and tomorrow from a web service, - processes the prices, and returns a list of prices for the specified duration starting - from the specified start time. If tomorrow's prices are not available, today's prices are - repeated for tomorrow. - - Args: - tgt_duration (int): The target duration in hours for which the prices are needed. - start_time (datetime, optional): The start time for fetching prices. Defaults to None. - - Returns: - list: A list of electricity prices for the specified duration starting - from the specified start time. - """ - logger.debug("[PRICES] Prices fetching from TIBBER started") - if config_manager.config["price"]["source"] != "tibber": - logger.error("[PRICES] Price source currently not supported.") - return [] - headers = { - "Authorization": config_manager.config["price"]["token"], - "Content-Type": "application/json", - } - query = """ - { - viewer { - homes { - currentSubscription { - priceInfo { - today { - total - startsAt - } - tomorrow { - total - startsAt - } - } - } - } - } - } - """ - try: - response = requests.post( - TIBBER_API, headers=headers, json={"query": query}, timeout=10 - ) - response.raise_for_status() - except requests.exceptions.Timeout: - logger.error("[PRICES] Request timed out while fetching prices from Tibber.") - return [] - except requests.exceptions.RequestException as e: - logger.error("[PRICES] Request failed while fetching prices from Tibber: %s", e) - return [] - - response.raise_for_status() - data = response.json() - if "errors" in data and data["errors"] is not None: - logger.error( - "[PRICES] Error fetching prices - tibber API response: %s", - data["errors"][0]["message"], - ) - return [] - - today_prices = json.dumps( - data["data"]["viewer"]["homes"][0]["currentSubscription"]["priceInfo"]["today"] - ) - tomorrow_prices = json.dumps( - data["data"]["viewer"]["homes"][0]["currentSubscription"]["priceInfo"][ - "tomorrow" - ] - ) - - today_prices_json = json.loads(today_prices) - tomorrow_prices_json = json.loads(tomorrow_prices) - prices = [] - - for price in today_prices_json: - prices.append(round(price["total"] / 1000, 9)) - # logger.debug( - # "[Main] day 1 - price for %s -> %s", price["startsAt"], price["total"] - # ) - if tomorrow_prices_json: - for price in tomorrow_prices_json: - prices.append(round(price["total"] / 1000, 9)) - # logger.debug( - # "[Main] day 2 - price for %s -> %s", price["startsAt"], price["total"] - # ) - else: - prices.extend(prices[:24]) # Repeat today's prices for tomorrow - - if start_time is None: - start_time = datetime.now(time_zone).replace(minute=0, second=0, microsecond=0) - current_hour = start_time.hour - extended_prices = prices[current_hour : current_hour + tgt_duration] - - if len(extended_prices) < tgt_duration: - remaining_hours = tgt_duration - len(extended_prices) - extended_prices.extend(prices[:remaining_hours]) - logger.info("[PRICES] Prices from TIBBER fetched successfully.") - return extended_prices +price_interface = PriceInterface( + config_manager.config["price"]["source"], + config_manager.config["price"]["token"], + config_manager.config["price"]["feed_in_price"], + config_manager.config["price"]["negative_price_switch"], +) def create_forecast_request(pv_config_entry): @@ -475,62 +274,8 @@ def create_optimize_request(): def get_ems_data(): return { "pv_prognose_wh": get_summarized_pv_forecast(EOS_TGT_DURATION), - "strompreis_euro_pro_wh": get_prices( - EOS_TGT_DURATION, - datetime.now(time_zone).replace( - hour=0, minute=0, second=0, microsecond=0 - ), - ), - "einspeiseverguetung_euro_pro_wh": [ - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - ], + "strompreis_euro_pro_wh": price_interface.get_current_prices(), + "einspeiseverguetung_euro_pro_wh": price_interface.get_current_feedin_prices(), "preis_euro_pro_wh_akku": 0, "gesamtlast": load_interface.get_load_profile(EOS_TGT_DURATION), } @@ -547,7 +292,9 @@ def get_pv_akku_data(): "max_charge_power_w": config_manager.config["battery"][ "max_charge_power_w" ], - "initial_soc_percentage": battery_interface.battery_request_current_soc(), + "initial_soc_percentage": round( + battery_interface.battery_request_current_soc() + ), "min_soc_percentage": config_manager.config["battery"][ "min_soc_percentage" ], @@ -564,8 +311,11 @@ def get_wechselrichter_data(): "max_power_wh": config_manager.config["inverter"]["max_pv_charge_rate"], } if eos_interface.get_eos_version() == ">=2025-04-09": - wechselrichter_object = {"device_id": "inverter1", **wechselrichter_object} # at top - wechselrichter_object["battery_id"] = "battery1" # at the bottom + wechselrichter_object = { + "device_id": "inverter1", + **wechselrichter_object, + } # at top + wechselrichter_object["battery_id"] = "battery1" # at the bottom return wechselrichter_object def get_eauto_data(): @@ -576,17 +326,14 @@ def get_eauto_data(): "max_charge_power_w": 7360, "initial_soc_percentage": 50, "min_soc_percentage": 5, - "max_soc_percentage": 100 + "max_soc_percentage": 100, } if eos_interface.get_eos_version() == ">=2025-04-09": eauto_object = {"device_id": "ev1", **eauto_object} return eauto_object def get_dishwasher_data(): - dishwaser_object = { - "consumption_wh": 1, - "duration_h": 1 - } + dishwaser_object = {"consumption_wh": 1, "duration_h": 1} if eos_interface.get_eos_version() == ">=2025-04-09": dishwaser_object = {"device_id": "dishwasher1", **dishwaser_object} return dishwaser_object @@ -610,6 +357,142 @@ def get_dishwasher_data(): return payload +class OptimizationScheduler: + """ + A scheduler class that manages the periodic execution of an optimization process + in a background thread. The class is responsible for starting, stopping, and + managing the lifecycle of the optimization service. + Attributes: + update_interval (int): The interval in seconds between optimization runs. + _update_thread (threading.Thread): The background thread running the optimization loop. + _stop_event (threading.Event): An event used to signal the thread to stop. + Methods: + start_update_service(): + shutdown(): + _update_state_loop(): + run_optimization(): + """ + + def __init__(self, update_interval): + self.update_interval = update_interval + self._update_thread = None + self._stop_event = threading.Event() + self.start_update_service() + + def start_update_service(self): + """ + Starts the background thread to periodically update the state. + """ + if self._update_thread is None or not self._update_thread.is_alive(): + self._stop_event.clear() + self._update_thread = threading.Thread( + target=self._update_state_loop, daemon=True + ) + self._update_thread.start() + logger.info("[BATTERY-IF] Update service started.") + + def shutdown(self): + """ + Stops the background thread and shuts down the update service. + """ + if self._update_thread and self._update_thread.is_alive(): + self._stop_event.set() + self._update_thread.join() + logger.info("[OPTIMIZATION] Update service stopped.") + + def _update_state_loop(self): + """ + The loop that runs in the background thread to update the state. + """ + while not self._stop_event.is_set(): + try: + self.run_optimization() + except (requests.exceptions.RequestException, ValueError, KeyError) as e: + logger.error("[BATTERY-IF] Error while updating state: %s", e) + # Break the sleep interval into smaller chunks to allow immediate shutdown + sleep_interval = self.update_interval + while sleep_interval > 0: + if self._stop_event.is_set(): + return # Exit immediately if stop event is set + time.sleep(min(1, sleep_interval)) # Sleep in 1-second chunks + sleep_interval -= 1 + + self.start_update_service() + + def run_optimization(self): + """ + Executes the optimization process by creating an optimization request, + sending it to the EOS interface, processing the response, and scheduling + the next optimization run. + The method performs the following steps: + 1. Logs the start of a new optimization run. + 2. Creates an optimization request in JSON format and saves it to a file. + 3. Sends the optimization request to the EOS interface and retrieves the response. + 4. Adds a timestamp to the response and saves it to a file. + 5. Extracts control data from the response and, if no error is detected, + applies the control settings and updates the control state. + 6. Calculates the time for the next optimization run and logs the sleep duration. + Raises: + Any exceptions raised during file operations, JSON serialization, + or EOS interface communication will propagate to the caller. + Notes: + - The method assumes the presence of global variables or objects such as + `logger`, `base_path`, `eos_interface`, `config_manager`, and `time_zone`. + - The `config_manager.config` dictionary is expected to contain the + necessary configuration values for "eos.timeout" and "refresh_time". + """ + logger.info("[Main] start new run") + # update prices + price_interface.update_prices( + EOS_TGT_DURATION, + datetime.now(time_zone).replace(hour=0, minute=0, second=0, microsecond=0), + ) + # create optimize request + json_optimize_input = create_optimize_request() + + with open( + base_path + "/json/optimize_request.json", "w", encoding="utf-8" + ) as file: + json.dump(json_optimize_input, file, indent=4) + + optimized_response = eos_interface.eos_set_optimize_request( + json_optimize_input, config_manager.config["eos"]["timeout"] + ) + optimized_response["timestamp"] = datetime.now(time_zone).isoformat() + + with open( + base_path + "/json/optimize_response.json", "w", encoding="utf-8" + ) as file: + json.dump(optimized_response, file, indent=4) + # +++++++++ + ac_charge_demand, dc_charge_demand, discharge_allowed, error = ( + eos_interface.examine_response_to_control_data(optimized_response) + ) + if error is not True: + setting_control_data(ac_charge_demand, dc_charge_demand, discharge_allowed) + change_control_state() + # +++++++++ + + loop_now = datetime.now(time_zone) + # Reset base to full minutes on the clock + next_eval = loop_now.replace(microsecond=0) + # Add the update interval to calculate the next evaluation time + next_eval += timedelta(seconds=self.update_interval) + sleeptime = (next_eval - loop_now).total_seconds() + minutes, seconds = divmod(sleeptime, 60) + logger.info( + "[Main] Next optimization at %s. Sleeping for %d min %.0f seconds\n", + next_eval.strftime("%H:%M:%S"), + minutes, + seconds, + ) + + +optimization_scheduler = OptimizationScheduler( + config_manager.config["refresh_time"] * 60 # convert to seconds +) + + def setting_control_data(ac_charge_demand_rel, dc_charge_demand_rel, discharge_allowed): """ Process the optimized response from EOS and update the load interface. @@ -707,6 +590,18 @@ def main_page(): return render_template_string(html_file.read()) +@app.route("/style.css", methods=["GET"]) +def style_css(): + """ + Serves the CSS file for styling the web application. + + This function reads the content of the 'style.css' file located in the 'web' directory + and returns it as a response with the appropriate content type. + """ + with open(base_path + "/web/style.css", "r", encoding="utf-8") as css_file: + return Response(css_file.read(), content_type="text/css") + + @app.route("/json/optimize_request.json", methods=["GET"]) def get_optimize_request(): """ @@ -765,15 +660,18 @@ def serve_current_demands(): current_ac_charge_demand = base_control.get_current_ac_charge_demand() current_dc_charge_demand = base_control.get_current_dc_charge_demand() current_discharge_allowed = base_control.get_current_discharge_allowed() + current_inverter_mode = base_control.get_current_overall_state(False) + current_battery_soc = battery_interface.get_current_soc() + base_control.set_current_battery_soc(current_battery_soc) response_data = { "current_states": { "current_ac_charge_demand": current_ac_charge_demand, "current_dc_charge_demand": current_dc_charge_demand, "current_discharge_allowed": current_discharge_allowed, - "inverter_mode": base_control.get_current_overall_state(False), + "inverter_mode": current_inverter_mode, "evcc_charging_state": base_control.get_current_evcc_charging_state(), }, - "battery_soc": base_control.get_current_battery_soc(), + "battery_soc": current_battery_soc, "timestamp": datetime.now(time_zone).isoformat(), } return Response(json.dumps(response_data), content_type="application/json") @@ -815,7 +713,10 @@ def serve_current_demands(): # ) as file: # json.dump(optimized_response, file, indent=4) + # time.sleep(30) + # evcc_interface.shutdown() + # battery_interface.shutdown() # if ( # config_manager.config["inverter"]["type"] == "fronius_gen24" # and inverter_interface is not None @@ -831,86 +732,14 @@ def serve_current_demands(): error_log=logger, ) - def run_optimization_loop(): - """ - Continuously runs the optimization loop until interrupted. - This function performs the following steps in an infinite loop: - 1. Logs the start of a new run. - 2. Creates an optimization request and saves it to a JSON file. - 3. Sends the optimization request and receives the optimized response. - 4. Adds a timestamp to the optimized response and saves it to a JSON file. - 5. Calculates the time to the next evaluation based on a predefined interval. - 6. Logs the next evaluation time and sleeps until that time. - The loop can be interrupted with a KeyboardInterrupt, which will log an exit message and - terminate the program. - Raises: - KeyboardInterrupt: If the loop is interrupted by the user. - """ - - scheduler = sched.scheduler(time.time, time.sleep) - - def run_optimization_event(sc): - logger.info("[Main] start new run") - # create optimize request - json_optimize_input = create_optimize_request() - - with open( - base_path + "/json/optimize_request.json", "w", encoding="utf-8" - ) as file: - json.dump(json_optimize_input, file, indent=4) - - optimized_response = eos_interface.eos_set_optimize_request( - json_optimize_input, config_manager.config["eos"]["timeout"] - ) - optimized_response["timestamp"] = datetime.now(time_zone).isoformat() - - with open( - base_path + "/json/optimize_response.json", "w", encoding="utf-8" - ) as file: - json.dump(optimized_response, file, indent=4) - # +++++++++ - ac_charge_demand, dc_charge_demand, discharge_allowed, error = ( - eos_interface.examine_response_to_control_data(optimized_response) - ) - if error is not True: - setting_control_data( - ac_charge_demand, dc_charge_demand, discharge_allowed - ) - change_control_state() - # +++++++++ - - loop_now = datetime.now(time_zone).astimezone() - # reset base to full minutes on the clock - next_eval = loop_now - timedelta( - minutes=loop_now.minute % config_manager.config["refresh_time"], - seconds=loop_now.second, - microseconds=loop_now.microsecond, - ) - # add time increments to trigger next evaluation - next_eval += timedelta( - minutes=config_manager.config["refresh_time"], seconds=0, microseconds=0 - ) - sleeptime = (next_eval - loop_now).total_seconds() - minutes, seconds = divmod(sleeptime, 60) - logger.info( - "[Main] Next optimization at %s. Sleeping for %d min %.0f seconds\n", - next_eval.astimezone(time_zone).strftime("%H:%M:%S"), - minutes, - seconds, - ) - scheduler.enter(sleeptime, 1, run_optimization_event, (sc,)) - - scheduler.enter(0, 1, run_optimization_event, (scheduler,)) - scheduler.run() - - optimization_thread = Thread(target=run_optimization_loop) - optimization_thread.start() - try: http_server.serve_forever() except KeyboardInterrupt: - logger.info("[Main] Shutting down server") + logger.info("[Main] Shutting down EOS connect") + optimization_scheduler.shutdown() http_server.stop() + logger.info("[Main] HTTP server stopped") + # restore the old config if ( config_manager.config["inverter"]["type"] == "fronius_gen24" @@ -918,19 +747,8 @@ def run_optimization_event(sc): ): inverter_interface.shutdown() evcc_interface.shutdown() - optimization_thread.join(timeout=10) - if optimization_thread.is_alive(): - logger.warning( - "[Main] Optimization thread did not finish in time, terminating." - ) - # Terminate the thread (not recommended, but shown here for completeness) - # Note: Python does not provide a direct way to kill a thread. This is a workaround. - import ctypes - - if optimization_thread.ident is not None: - ctypes.pythonapi.PyThreadState_SetAsyncExc( - ctypes.c_long(optimization_thread.ident), - ctypes.py_object(SystemExit), - ) + battery_interface.shutdown() logger.info("[Main] Server stopped") + finally: + logger.info("[Main] Cleanup complete. Exiting.") sys.exit(0) diff --git a/src/interfaces/base_control.py b/src/interfaces/base_control.py index 78529094..a6615511 100644 --- a/src/interfaces/base_control.py +++ b/src/interfaces/base_control.py @@ -200,4 +200,4 @@ def set_current_battery_soc(self, value): Sets the current battery state of charge (SOC). """ self.current_battery_soc = value - logger.debug("[BASE_CTRL] set current battery SOC to %s", value) + # logger.debug("[BASE_CTRL] set current battery SOC to %s", value) diff --git a/src/interfaces/battery_interface.py b/src/interfaces/battery_interface.py index 4101b0ac..989bc2c0 100644 --- a/src/interfaces/battery_interface.py +++ b/src/interfaces/battery_interface.py @@ -25,6 +25,8 @@ ``` """ import logging +import threading +import time import requests logger = logging.getLogger("__main__") @@ -55,6 +57,10 @@ def __init__(self, src, url, soc_sensor, access_token): self.soc_sensor = soc_sensor self.access_token = access_token self.current_soc = 0 + self.update_interval = 30 + self._update_thread = None + self._stop_event = threading.Event() + self.start_update_service() def fetch_soc_data_from_openhab(self): """ @@ -107,7 +113,7 @@ def fetch_soc_data_from_homeassistant(self): requests.exceptions.Timeout: If the request to the Home Assistant API times out. requests.exceptions.RequestException: If there is an error during the request. """ - logger.debug("[BATTERY-IF] getting SOC from homeassistant ...") + # logger.debug("[BATTERY-IF] getting SOC from homeassistant ...") homeassistant_url = f"{self.url}/api/states/{self.soc_sensor}" # Headers for the API request headers = { @@ -122,8 +128,8 @@ def fetch_soc_data_from_homeassistant(self): # print(f'Entity data: {entity_data}') soc = float(entity_data["state"]) # print(f'State: {state}') - logger.info("[BATTERY-IF] successfully fetched SOC = %s %%", soc) - return round(soc) + logger.debug("[BATTERY-IF] successfully fetched SOC = %s %%", soc) + return round(soc,1) except requests.exceptions.Timeout: logger.error( ( @@ -166,3 +172,43 @@ def get_current_soc(self): Returns the current state of charge (SOC) of the battery. """ return self.current_soc + + def start_update_service(self): + """ + Starts the background thread to periodically update the state. + """ + if self._update_thread is None or not self._update_thread.is_alive(): + self._stop_event.clear() + self._update_thread = threading.Thread( + target=self._update_state_loop, daemon=True + ) + self._update_thread.start() + logger.info("[BATTERY-IF] Update service started.") + + def shutdown(self): + """ + Stops the background thread and shuts down the update service. + """ + if self._update_thread and self._update_thread.is_alive(): + self._stop_event.set() + self._update_thread.join() + logger.info("[BATTERY-IF] Update service stopped.") + + def _update_state_loop(self): + """ + The loop that runs in the background thread to update the state. + """ + while not self._stop_event.is_set(): + try: + self.battery_request_current_soc() + except (requests.exceptions.RequestException, ValueError, KeyError) as e: + logger.error("[BATTERY-IF] Error while updating state: %s", e) + # Break the sleep interval into smaller chunks to allow immediate shutdown + sleep_interval = self.update_interval + while sleep_interval > 0: + if self._stop_event.is_set(): + return # Exit immediately if stop event is set + time.sleep(min(1, sleep_interval)) # Sleep in 1-second chunks + sleep_interval -= 1 + + self.start_update_service() diff --git a/src/interfaces/load_interface.py b/src/interfaces/load_interface.py index b085dc16..80b7306a 100644 --- a/src/interfaces/load_interface.py +++ b/src/interfaces/load_interface.py @@ -400,7 +400,8 @@ def get_load_profile_for_day(self, start_time, end_time): if not load_profile: logger.error( "[LOAD-IF] No load profile data available for the specified day - % s to % s", - start_time, end_time + start_time, + end_time, ) return load_profile @@ -459,12 +460,15 @@ def create_load_profile_homeassistant_weekdays(self): # combine to a list with 48 values load_profile = [] for i, value in enumerate(load_profile_one_week_before): - if load_profile_two_week_before: + if load_profile_two_week_before and len(load_profile_two_week_before) >= 24: load_profile.append((value + load_profile_two_week_before[i]) / 2) else: load_profile.append(value) for i, value in enumerate(load_profile_tomorrow_one_week_before): - if load_profile_tomorrow_two_week_before: + if ( + load_profile_tomorrow_two_week_before + and len(load_profile_tomorrow_two_week_before) >= 24 + ): load_profile.append( (value + load_profile_tomorrow_two_week_before[i]) / 2 ) diff --git a/src/interfaces/price_interface.py b/src/interfaces/price_interface.py new file mode 100644 index 00000000..2b51d2af --- /dev/null +++ b/src/interfaces/price_interface.py @@ -0,0 +1,385 @@ +''' +This module provides the `PriceInterface` class, which is designed to handle electricity price +data retrieval, processing, and management from various sources. It includes methods for fetching +current prices, generating feed-in prices, and updating price data for a specified duration and +start time. The module supports integration with the Akkudoktor API and Tibber API for retrieving +electricity prices. +Classes: + PriceInterface: A class for managing electricity price data, including fetching, processing, + and generating feed-in prices. +Constants: + AKKUDOKTOR_API_PRICES (str): The URL for the Akkudoktor API to fetch electricity prices. + TIBBER_API (str): The URL for the Tibber API to fetch electricity prices. +Dependencies: + - datetime: For handling date and time operations. + - json: For parsing JSON responses from APIs. + - logging: For logging messages and errors. + - requests: For making HTTP requests to APIs. +Usage: + Create an instance of the `PriceInterface` class with the desired configuration, and use its + methods to fetch and process electricity price data. +Example: + price_interface = PriceInterface( + src="tibber", + access_token="your_access_token", + feed_in_tariff_price=5.0, + negative_price_switch=True, + timezone="Europe/Berlin" + price_interface.update_prices(tgt_duration=24, start_time=datetime.now()) + current_prices = price_interface.get_current_prices() + current_feedin_prices = price_interface.get_current_feedin_prices() +''' +from datetime import datetime, timedelta +import json +import logging +import requests + +logger = logging.getLogger("__main__") +logger.info("[PRICE-IF] loading module ") + +AKKUDOKTOR_API_PRICES = "https://api.akkudoktor.net/prices" +TIBBER_API = "https://api.tibber.com/v1-beta/gql" + + +class PriceInterface: + ''' + PriceInterface is a class designed to handle electricity price data retrieval, processing, + and management from various sources. It provides methods to fetch current prices, + generate feed-in prices, and update price data for a specified duration and start time. + Attributes: + src (str): The source of the price data (e.g., 'tibber', 'default'). + access_token (str): The access token for authenticating with the price source. + feed_in_tariff_price (float): The feed-in tariff price in cents per kWh. + negative_price_switch (bool): A flag to determine whether to set feed-in prices to 0 + for negative prices. + timezone (str): The timezone used for date and time operations. + current_prices (list): A list of current prices fetched from the price source. + current_prices_direct (list): A list of current prices without tax. + current_feedin (list): A list of current feed-in prices. + Methods: + update_prices(tgt_duration, start_time): + Updates the current prices and feed-in prices based on the target duration and + start time. + get_current_prices(): + Returns the current prices fetched from the price source. + get_current_feedin_prices(): + Returns the current feed-in prices fetched from the price source. + __create_feedin_prices(): + Creates feed-in prices based on the current prices and the configured feed-in + tariff price. + __retrieve_prices(tgt_duration, start_time=None): + Retrieves prices based on the target duration and optional start time from the + configured source. + __retrieve_prices_from_akkudoktor(tgt_duration, start_time=None): + Fetches and processes electricity prices for today and tomorrow from the + Akkudoktor API. + __retrieve_prices_from_tibber(tgt_duration, start_time=None): + Fetches and processes electricity prices for today and tomorrow from the Tibber API. + ''' + def __init__( + self, + src, + access_token, + feed_in_tariff_price=0.0, + negative_price_switch=False, + timezone="UTC", + ): + self.src = src + self.access_token = access_token + self.feed_in_tariff_price = feed_in_tariff_price + self.negative_price_switch = negative_price_switch + self.time_zone = timezone + self.current_prices = [] + self.current_prices_direct = [] # without tax + self.current_feedin = [] + + def update_prices(self, tgt_duration, start_time): + """ + Updates the current prices and feed-in prices based on the target duration + and start time provided. + + Args: + tgt_duration (int): The target duration for which prices need to be retrieved. + start_time (datetime): The starting time for retrieving prices. + + Updates: + self.current_prices: Updates with the retrieved prices for the given duration + and start time. + self.current_feedin: Updates with the generated feed-in prices. + + Logs: + Logs a debug message indicating that prices have been updated. + """ + self.current_prices = self.__retrieve_prices(tgt_duration, start_time) + self.current_feedin = self.__create_feedin_prices() + logger.debug("[PRICE-IF] Prices updated") + + def get_current_prices(self): + """ + Returns the current prices. + + This function returns the current prices fetched from the price source. + If the source is not supported, it returns an empty list. + + Returns: + list: A list of current prices. + """ + return self.current_prices + + def get_current_feedin_prices(self): + """ + Returns the current feed-in prices. + + This function returns the current feed-in prices fetched from the price source. + If the source is not supported, it returns an empty list. + + Returns: + list: A list of current feed-in prices. + """ + return self.current_feedin + + def __create_feedin_prices(self): + """ + Creates feed-in prices based on the current prices. + + This function generates feed-in prices based on the current prices and the + configured feed-in tariff price. If the negative price switch is enabled, + feed-in prices are set to 0 for negative prices. Otherwise, the feed-in tariff + price is used for all prices. + + Returns: + list: A list of feed-in prices. + """ + if self.negative_price_switch: + self.current_feedin = [ + 0 if price < 0 else round(self.feed_in_tariff_price / 1000, 9) + for price in self.current_prices_direct + ] + else: + self.current_feedin = [ + round(self.feed_in_tariff_price / 1000, 9) + for price in self.current_prices_direct + ] + return self.current_feedin + + def __retrieve_prices(self, tgt_duration, start_time=None): + """ + Retrieve prices based on the target duration and optional start time. + + This function fetches prices from different sources based on the configuration. + It supports fetching prices from 'tibber' and 'default' sources. + + Args: + tgt_duration (int): The target duration for which prices are to be fetched. + start_time (datetime, optional): The start time from which prices are to be fetched. + Defaults to None. + + Returns: + list: A list of prices for the specified duration and start time. Returns an empty list + if the price source is not supported. + """ + if self.src == "tibber": + return self.__retrieve_prices_from_tibber(tgt_duration, start_time) + if self.src == "default": + return self.__retrieve_prices_from_akkudoktor(tgt_duration, start_time) + logger.error("[PRICE-IF] Price source currently not supported.") + return [] + + def __retrieve_prices_from_akkudoktor(self, tgt_duration, start_time=None): + """ + Fetches and processes electricity prices for today and tomorrow. + + This function retrieves electricity prices for today and tomorrow from an API, + processes the prices, and returns a list of prices for the specified duration starting + from the specified start time. If tomorrow's prices are not available, today's prices are + repeated for tomorrow. + + Args: + tgt_duration (int): The target duration in hours for which the prices are needed. + start_time (datetime, optional): The start time for fetching prices. Defaults to None. + + Returns: + list: A list of electricity prices for the specified duration starting + from the specified start time. + """ + if self.src != "default": + logger.error( + "[PRICE-IF] Price source %s currently not supported.", + self.src, + ) + return [] + logger.debug("[PRICE-IF] Fetching prices from akkudoktor ...") + if start_time is None: + start_time = datetime.now(self.time_zone).replace( + minute=0, second=0, microsecond=0 + ) + current_hour = start_time.hour + request_url = ( + AKKUDOKTOR_API_PRICES + + "?start=" + + start_time.strftime("%Y-%m-%d") + + "&end=" + + (start_time + timedelta(days=1)).strftime("%Y-%m-%d") + ) + logger.debug("[PRICE-IF] Requesting prices from akkudoktor: %s", request_url) + try: + response = requests.get(request_url, timeout=10) + response.raise_for_status() + data = response.json() + except requests.exceptions.Timeout: + logger.error( + "[PRICE-IF] Request timed out while fetching prices from akkudoktor." + ) + return [] + except requests.exceptions.RequestException as e: + logger.error( + "[PRICE-IF] Request failed while fetching prices from akkudoktor: %s", e + ) + return [] + + prices = [] + for price in data["values"]: + prices.append(round(price["marketpriceEurocentPerKWh"] / 100000, 9)) + # logger.debug( + # "[Main] day 1 - price for %s -> %s", price["marketpriceEurocentPerKWh"], + # price["start"] + # ) + + if start_time is None: + start_time = datetime.now(self.time_zone).replace( + minute=0, second=0, microsecond=0 + ) + current_hour = start_time.hour + extended_prices = prices[current_hour : current_hour + tgt_duration] + + if len(extended_prices) < tgt_duration: + remaining_hours = tgt_duration - len(extended_prices) + extended_prices.extend(prices[:remaining_hours]) + logger.info("[PRICE-IF] Prices from AKKUDOKTOR fetched successfully.") + self.current_prices_direct = extended_prices.copy() + return extended_prices + + def __retrieve_prices_from_tibber(self, tgt_duration, start_time=None): + """ + Fetches and processes electricity prices for today and tomorrow. + + This function retrieves electricity prices for today and tomorrow from a web service, + processes the prices, and returns a list of prices for the specified duration starting + from the specified start time. If tomorrow's prices are not available, today's prices are + repeated for tomorrow. + + Args: + tgt_duration (int): The target duration in hours for which the prices are needed. + start_time (datetime, optional): The start time for fetching prices. Defaults to None. + + Returns: + list: A list of electricity prices for the specified duration starting + from the specified start time. + """ + logger.debug("[PRICE-IF] Prices fetching from TIBBER started") + if self.src != "tibber": + logger.error("[PRICE-IF] Price source currently not supported.") + return [] + headers = { + "Authorization": self.access_token, + "Content-Type": "application/json", + } + query = """ + { + viewer { + homes { + currentSubscription { + priceInfo { + today { + total + energy + startsAt + } + tomorrow { + total + energy + startsAt + } + } + } + } + } + } + """ + try: + response = requests.post( + TIBBER_API, headers=headers, json={"query": query}, timeout=10 + ) + response.raise_for_status() + except requests.exceptions.Timeout: + logger.error( + "[PRICE-IF] Request timed out while fetching prices from Tibber." + ) + return [] + except requests.exceptions.RequestException as e: + logger.error( + "[PRICE-IF] Request failed while fetching prices from Tibber: %s", e + ) + return [] + + response.raise_for_status() + data = response.json() + if "errors" in data and data["errors"] is not None: + logger.error( + "[PRICE-IF] Error fetching prices - tibber API response: %s", + data["errors"][0]["message"], + ) + return [] + + today_prices = json.dumps( + data["data"]["viewer"]["homes"][0]["currentSubscription"]["priceInfo"][ + "today" + ] + ) + tomorrow_prices = json.dumps( + data["data"]["viewer"]["homes"][0]["currentSubscription"]["priceInfo"][ + "tomorrow" + ] + ) + + today_prices_json = json.loads(today_prices) + tomorrow_prices_json = json.loads(tomorrow_prices) + prices = [] + prices_direct = [] + + for price in today_prices_json: + prices.append(round(price["total"] / 1000, 9)) + prices_direct.append(round(price["energy"] / 1000, 9)) + # logger.debug( + # "[Main] day 1 - price for %s -> %s", price["startsAt"], price["total"] + # ) + if tomorrow_prices_json: + for price in tomorrow_prices_json: + prices.append(round(price["total"] / 1000, 9)) + prices_direct.append(round(price["energy"] / 1000, 9)) + # logger.debug( + # "[Main] day 2 - price for %s -> %s", price["startsAt"], price["total"] + # ) + else: + prices.extend(prices[:24]) # Repeat today's prices for tomorrow + prices_direct.extend( + prices_direct[:24] + ) # Repeat today's prices for tomorrow + + if start_time is None: + start_time = datetime.now(self.time_zone).replace( + minute=0, second=0, microsecond=0 + ) + current_hour = start_time.hour + extended_prices = prices[current_hour : current_hour + tgt_duration] + extended_prices_direct = prices_direct[ + current_hour : current_hour + tgt_duration + ] + + if len(extended_prices) < tgt_duration: + remaining_hours = tgt_duration - len(extended_prices) + extended_prices.extend(prices[:remaining_hours]) + extended_prices_direct.extend(prices_direct[:remaining_hours]) + self.current_prices_direct = extended_prices_direct.copy() + logger.info("[PRICE-IF] Prices from TIBBER fetched successfully.") + return extended_prices From 6efc236698ffaaa903c774e59cccf8867d66315d Mon Sep 17 00:00:00 2001 From: ohAnd <15704728+ohAnd@users.noreply.github.com> Date: Sat, 12 Apr 2025 22:40:05 +0200 Subject: [PATCH 2/3] remerge with main --- src/interfaces/evcc_interface.py | 8 +- src/web/index.html | 183 ++++--------------------------- src/web/style.css | 151 +++++++++++++++++++++++++ 3 files changed, 178 insertions(+), 164 deletions(-) create mode 100644 src/web/style.css diff --git a/src/interfaces/evcc_interface.py b/src/interfaces/evcc_interface.py index c982f42a..e81e4cef 100644 --- a/src/interfaces/evcc_interface.py +++ b/src/interfaces/evcc_interface.py @@ -101,7 +101,13 @@ def _update_charging_state_loop(self): self.request_charging_state() except (requests.exceptions.RequestException, ValueError, KeyError) as e: logger.error("[EVCC] Error while updating charging state: %s", e) - time.sleep(self.update_interval) + # Break the sleep interval into smaller chunks to allow immediate shutdown + sleep_interval = self.update_interval + while sleep_interval > 0: + if self._stop_event.is_set(): + return # Exit immediately if stop event is set + time.sleep(min(1, sleep_interval)) # Sleep in 1-second chunks + sleep_interval -= 1 self.start_update_service() diff --git a/src/web/index.html b/src/web/index.html index e1a0306c..907e97e6 100644 --- a/src/web/index.html +++ b/src/web/index.html @@ -6,161 +6,9 @@ EOS connect board - + @@ -581,6 +429,17 @@ } + async function showCurrentData() { + console.log("------- showCurrentControls -------"); + const data_controls = await fetch_EOS_Connect_Data("current_controls.json"); + document.getElementById('control_ac_charge').innerText = data_controls["current_states"]["current_ac_charge_demand"].toFixed(0) + " Wh"; + document.getElementById('control_dc_charge').innerText = data_controls["current_states"]["current_dc_charge_demand"].toFixed(0) + " Wh"; + document.getElementById('control_discharge_allowed').innerText = (data_controls["current_states"]["current_discharge_allowed"] === true ? "Yes" : "No"); + document.getElementById('control_overall').innerText = data_controls["current_states"]["inverter_mode"]; + + document.getElementById('battery_soc').innerText = data_controls["battery_soc"] + " %"; + } + function showBatteryCharge(data_request, data_response) { console.log("------- showBatteryCharge -------"); var discharge_allowed = data_response["discharge_allowed"]; @@ -672,7 +531,12 @@ if(data_response["error"] === "Request timed out"){ document.getElementById('waiting_text').innerText = "No data available..."; document.getElementById('waiting_error_text').innerText = "Error: " + data_response["error"]; - } else { + } + else if(data_response["error"].includes("422 Client Error: Unprocessable Entity")){ + document.getElementById('waiting_text').innerText = "No data available..."; + document.getElementById('waiting_error_text').innerText = "Error: " + data_response["error"]; + } + else { document.getElementById('waiting_text').innerText = "No data available..."; document.getElementById('waiting_error_text').innerText = "no further error information available"; } @@ -703,15 +567,8 @@ //document.getElementById('timestamp_battery').innerHTML = data["energyBatteryChargeDischargeJSON"].timestamp; showBatteryCharge(data_request,data_response); - //await displayCurrentValues(data); - - const data_controls = await fetch_EOS_Connect_Data("current_controls.json"); - document.getElementById('control_ac_charge').innerText = data_controls["current_states"]["current_ac_charge_demand"].toFixed(0) + " Wh"; - document.getElementById('control_dc_charge').innerText = data_controls["current_states"]["current_dc_charge_demand"].toFixed(0) + " Wh"; - document.getElementById('control_discharge_allowed').innerText = (data_controls["current_states"]["current_discharge_allowed"] === true ? "Yes" : "No"); - document.getElementById('control_overall').innerText = data_controls["current_states"]["inverter_mode"]; - - document.getElementById('battery_soc').innerText = data_controls["battery_soc"] + " %"; + + await showCurrentData(); var currentHour = new Date().getHours(); diff --git a/src/web/style.css b/src/web/style.css new file mode 100644 index 00000000..a0d38d93 --- /dev/null +++ b/src/web/style.css @@ -0,0 +1,151 @@ +body { + margin: 0; + font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; + display: flex; + flex-direction: column; + height: 100vh; + background-color: rgb(54, 54, 54); + color: lightgray; +} + +.container { + display: flex; + flex-direction: column; + height: 100%; +} + +.top-boxes, +.bottom-boxes { + display: flex; +} + +.top-boxes { + height: 20%; +} + +.top-box { + flex: 1; + margin: 10px; + padding: 10px; + background-color: rgb(78, 78, 78); + box-shadow: 0 0 10px rgba(0, 0, 0, 0.5); + border-radius: 10px; + display: flex; + flex-direction: column; + font-size: 0.93em; +} + +.bottom-boxes { + height: 80%; +} + +.left-box { + width: 75%; + margin: 10px; + padding: 10px; + border-radius: 10px; + background-color: rgb(78, 78, 78); +} + +.right-box { + width: 24%; + margin: 10px; + padding: 10px; + border-radius: 10px; + background-color: rgb(78, 78, 78); + display: flex; + flex-direction: column; +} + +.header { + background-color: rgb(114, 114, 114); + ; + color: white; + padding: 10px; + text-align: center; + border-radius: 10px; + position: relative; +} + +.header_notification { + background-color: rgb(58, 58, 58); + color: white; + padding: 4px; + text-align: center; + border-radius: 5px; + font-size: 0.65em; + position: absolute; + top: 10px; + right: 10px; +} + +.content { + padding: 10px; + flex: 1; + overflow: auto; +} + +table { + width: 100%; + border-collapse: collapse; +} + +th, +td { + padding: 0 5px 0 5px; + text-align: left; +} +th { + text-align:center; +} + +/* Media Queries for Smartphones */ +@media (max-width: 768px) { + .top-boxes { + flex-direction: column; + height: auto; + } + + .top-box { + min-height: 150px; + font-size: 0.73em; + } + + .bottom-boxes { + flex-direction: column; + height: auto; + } + + .left-box, + .right-box { + width: auto; + height: auto; + } +} + +.table { + display: table; + width: 100%; + border-collapse: collapse; +} + +.table-header, .table-body { + display: table-row-group; +} + +.table-row { + display: table-row; +} + +.table-cell { + display: table-cell; + padding: 1px 5px; + text-align: left; + margin: 2px; + border-radius: 20px; +} + +.table-cell.rounded { + border-radius: 10px; + overflow: hidden; +} \ No newline at end of file From bea475165251b2d089795cc9224f4378c85aadf2 Mon Sep 17 00:00:00 2001 From: ohAnd <15704728+ohAnd@users.noreply.github.com> Date: Sun, 13 Apr 2025 10:56:50 +0200 Subject: [PATCH 3/3] feat: Add dynamic maximum charge power calculation (charging curve) and update UI to display current values --- README.md | 1 + src/eos_connect.py | 68 +++++++---------------------- src/interfaces/battery_interface.py | 63 +++++++++++++++++++++----- src/web/index.html | 37 ++++++++-------- 4 files changed, 88 insertions(+), 81 deletions(-) diff --git a/README.md b/README.md index 64adfc34..12f659e5 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,7 @@ EOS Connect is a tool designed to optimize energy usage by interacting with the * Manages configurations via a user-friendly config.yaml file. * Displays results dynamically on a webpage. * Controlling FRONIUS inverters and battery charging systems interactively. +* Battery charging is dynamicaly limited depending on the SOC (charging curve) ## Current Status diff --git a/src/eos_connect.py b/src/eos_connect.py index 881d86d0..06de8cbe 100644 --- a/src/eos_connect.py +++ b/src/eos_connect.py @@ -9,7 +9,6 @@ import logging import json import threading -import sched import pytz import requests from flask import Flask, Response, render_template_string @@ -137,6 +136,7 @@ def charging_state_callback(new_state): config_manager.config.get("battery", {}).get("url", ""), config_manager.config.get("battery", {}).get("soc_sensor", ""), config_manager.config.get("battery", {}).get("access_token", ""), + config_manager.config.get("battery", {}).get("max_charge_power_w", ""), ) price_interface = PriceInterface( @@ -146,7 +146,6 @@ def charging_state_callback(new_state): config_manager.config["price"]["negative_price_switch"], ) - def create_forecast_request(pv_config_entry): """ Creates a forecast request URL for the EOS server. @@ -209,7 +208,11 @@ def get_pv_forecast(tgt_value="power", pv_config_entry=None, tgt_duration=24): for forecast in forecast_entry: entry_time = datetime.fromisoformat(forecast["datetime"]).astimezone() if current_time <= entry_time < end_time: - forecast_values.append(forecast.get(tgt_value, 0)) + value = forecast.get(tgt_value, 0) + # if power is negative, set it to 0 (fixing wrong values form api) + if tgt_value == "power" and value < 0: + value = 0 + forecast_values.append(value) request_type = "PV forecast" pv_config_name = "for " + pv_config_entry["name"] if tgt_value == "temperature": @@ -538,13 +541,17 @@ def change_control_state(): logger.debug("[Main] Overall state changed recently") # MODE_CHARGE_FROM_GRID if base_control.get_current_overall_state() == 0: + # get the current ac charge demand and set it to the inverter according + # to the max dynamic charge power of the battery based on SOC + tgt_charge_power = min( + base_control.get_current_ac_charge_demand(), + round(battery_interface.get_max_charge_power_dyn()) + ) if inverter_en: - inverter_interface.set_mode_force_charge( - base_control.get_current_ac_charge_demand() - ) + inverter_interface.set_mode_force_charge(tgt_charge_power) logger.info( "[Main] Inverter mode set to charge from grid with %s W (_____|||||_____)", - base_control.get_current_ac_charge_demand(), + tgt_charge_power, ) # MODE_AVOID_DISCHARGE elif base_control.get_current_overall_state() == 1: @@ -672,58 +679,13 @@ def serve_current_demands(): "evcc_charging_state": base_control.get_current_evcc_charging_state(), }, "battery_soc": current_battery_soc, + "battery_max_charge_power_dyn": battery_interface.get_max_charge_power_dyn(), "timestamp": datetime.now(time_zone).isoformat(), } return Response(json.dumps(response_data), content_type="application/json") if __name__ == "__main__": - # initial config - # set_config_value("latitude", 48.812) - # set_config_value("longitude", 8.907) - - # set_config_value("measurement_load0_name", "Household") - # set_config_value("loadakkudoktor_year_energy", 4600) - - # # set_config_value("pvforecast_provider", "PVForecastAkkudoktor") - # set_config_value("pvforecast_provider", "PVForecast") - # set_config_value("pvforecast0_surface_tilt", 31) - # set_config_value("pvforecast0_surface_azimuth", 13) - # set_config_value("pvforecast0_peakpower", 860.0) - # set_config_value("pvforecast0_inverter_paco", 800) - # # set_config_value("pvforecast0_userhorizon", [0,0]) - - # # persist and update config - # eos_save_config_to_config_file() - - # json_optimize_input = create_optimize_request() - - # with open( - # base_path + "/json/optimize_request.json", "w", encoding="utf-8" - # ) as file: - # json.dump(json_optimize_input, file, indent=4) - - # optimized_response = eos_interface.eos_set_optimize_request( - # json_optimize_input, config_manager.config["eos"]["timeout"] - # ) - # optimized_response["timestamp"] = datetime.now(time_zone).isoformat() - - # with open( - # base_path + "/json/optimize_response.json", "w", encoding="utf-8" - # ) as file: - # json.dump(optimized_response, file, indent=4) - - # time.sleep(30) - - # evcc_interface.shutdown() - # battery_interface.shutdown() - # if ( - # config_manager.config["inverter"]["type"] == "fronius_gen24" - # and inverter_interface is not None - # ): - # inverter_interface.shutdown() - - # sys.exit() http_server = WSGIServer( ("0.0.0.0", config_manager.config["eos_connect_web_port"]), diff --git a/src/interfaces/battery_interface.py b/src/interfaces/battery_interface.py index 989bc2c0..b2f89c96 100644 --- a/src/interfaces/battery_interface.py +++ b/src/interfaces/battery_interface.py @@ -1,8 +1,19 @@ """ -This module provides the `BatteryInterface` class, which serves as an interface for fetching +- BatteryInterface: A class to interact with SOC data sources, retrieve battery SOC, and calculate + dynamic maximum charge power based on SOC. +- threading: For managing background update services. +- time: For managing sleep intervals in the update loop. +sensor identifier, access token (if required), and maximum fixed charge power. Use the +`battery_request_current_soc` method to fetch the current SOC value or `get_max_charge_power_dyn` +to calculate the dynamic maximum charge power. + access_token=None, + max_charge_power_w=3000 +max_charge_power = battery_interface.get_max_charge_power_dyn() +print(f"Max Charge Power: {max_charge_power}W") +This module provides the `BatteryInterface` class, which serves as an interface for fetching the State of Charge (SOC) data of a battery from various sources such as OpenHAB and Home Assistant. -The `BatteryInterface` class allows users to configure the source of SOC data and retrieve the -current SOC value through its methods. It supports fetching SOC data from OpenHAB using its REST API +The `BatteryInterface` class allows users to configure the source of SOC data and retrieve the +current SOC value through its methods. It supports fetching SOC data from OpenHAB using its REST API and from Home Assistant using its API with authentication. Classes: - BatteryInterface: A class to interact with SOC data sources and retrieve battery SOC. @@ -24,6 +35,7 @@ print(f"Current SOC: {current_soc}%") ``` """ + import logging import threading import time @@ -51,11 +63,12 @@ class BatteryInterface: Fetches the current SOC of the battery based on the configured source. """ - def __init__(self, src, url, soc_sensor, access_token): + def __init__(self, src, url, soc_sensor, access_token, max_charge_power_w): self.src = src self.url = url self.soc_sensor = soc_sensor self.access_token = access_token + self.max_charge_power_fix = max_charge_power_w self.current_soc = 0 self.update_interval = 30 self._update_thread = None @@ -87,7 +100,8 @@ def fetch_soc_data_from_openhab(self): except requests.exceptions.Timeout: logger.error( "[BATTERY-IF] OPENHAB - Request timed out while fetching battery SOC. " - "Using default SOC = %s%%.", soc + "Using default SOC = %s%%.", + soc, ) return soc # Default SOC value in case of timeout except requests.exceptions.RequestException as e: @@ -96,7 +110,8 @@ def fetch_soc_data_from_openhab(self): "[BATTERY-IF] OPENHAB - Request failed while fetching battery SOC: %s. " "Using default SOC = %s%%." ), - e,soc + e, + soc, ) return soc # Default SOC value in case of request failure @@ -129,12 +144,13 @@ def fetch_soc_data_from_homeassistant(self): soc = float(entity_data["state"]) # print(f'State: {state}') logger.debug("[BATTERY-IF] successfully fetched SOC = %s %%", soc) - return round(soc,1) + return round(soc, 1) except requests.exceptions.Timeout: logger.error( ( "[BATTERY-IF] HOMEASSISTANT - Request timed out while fetching battery SOC. " - "Using default SOC = %s%%.", soc + "Using default SOC = %s%%.", + soc, ) ) return soc # Default SOC value in case of timeout @@ -144,7 +160,8 @@ def fetch_soc_data_from_homeassistant(self): "[BATTERY-IF] HOMEASSISTANT - Request failed while fetching battery SOC: %s. " "Using default SOC = %s %%." ), - e,soc + e, + soc, ) return soc # Default SOC value in case of request failure @@ -173,6 +190,32 @@ def get_current_soc(self): """ return self.current_soc + def get_max_charge_power_dyn(self, soc=None): + """ + Calculates the maximum charge power of the battery depending on the SOC. + + - If SOC < 60%, the fixed maximum charge power is used. + - If SOC >= 60% and < 95%, the maximum charge power decreases linearly. + - If SOC >= 95%, the maximum charge power is fixed at 500W. + + Args: + soc (float, optional): The state of charge to use for calculation. + If None, the current SOC is used. + + Returns: + float: The dynamically calculated maximum charge power in watts. + """ + if soc is None: + soc = self.current_soc + + if soc < 70: + return self.max_charge_power_fix + elif soc >= 70 and soc < 95: + return max(500, self.max_charge_power_fix * ((95 - soc) / 35)) + # return max(500, self.max_charge_power_fix * (1 - (soc / 100)**2)) + else: # SOC >= 95 + return 500 + def start_update_service(self): """ Starts the background thread to periodically update the state. @@ -203,7 +246,7 @@ def _update_state_loop(self): self.battery_request_current_soc() except (requests.exceptions.RequestException, ValueError, KeyError) as e: logger.error("[BATTERY-IF] Error while updating state: %s", e) - # Break the sleep interval into smaller chunks to allow immediate shutdown + # Break the sleep interval into smaller chunks to allow immediate shutdown sleep_interval = self.update_interval while sleep_interval > 0: if self._stop_event.is_set(): diff --git a/src/web/index.html b/src/web/index.html index 907e97e6..df2f79a3 100644 --- a/src/web/index.html +++ b/src/web/index.html @@ -72,8 +72,8 @@ - ... - -- + Dynamic Max Charge Power + -- ... @@ -111,7 +111,7 @@
-
Current Power
+
eCar Charging
@@ -120,10 +120,10 @@ - + - - + + - - - - + + + + - - - - + + + + - - - - + + + +
currentCurrent SOC Price-.-- €--.- %
@@ -131,22 +131,22 @@
Grid--- WPrice-.-- €Planned Start--:--Planned End--:--
Inverter--- WPriceNeeded Amount--- kWhPrice-.-- €
Load--- WPrice-.-- €
@@ -438,6 +438,7 @@ document.getElementById('control_overall').innerText = data_controls["current_states"]["inverter_mode"]; document.getElementById('battery_soc').innerText = data_controls["battery_soc"] + " %"; + document.getElementById('current_max_charge_dyn').innerText = data_controls["battery_max_charge_power_dyn"].toFixed(0) + " W"; } function showBatteryCharge(data_request, data_response) {