From c6e11d88e2e5cff007c4c843483ea51ffab6821a Mon Sep 17 00:00:00 2001 From: Trefor Southwell <48591903+springfall2008@users.noreply.github.com> Date: Sat, 8 Jun 2024 18:54:40 +0100 Subject: [PATCH] Walk back split up changes, fix downloader first (#1194) * Walk back split up changes, fix downloader first * [pre-commit.ci lite] apply automatic fixes --------- Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> --- apps/predbat/predbat.py | 4753 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 4687 insertions(+), 66 deletions(-) diff --git a/apps/predbat/predbat.py b/apps/predbat/predbat.py index becbc9c6..cb82a2da 100644 --- a/apps/predbat/predbat.py +++ b/apps/predbat/predbat.py @@ -1,84 +1,4438 @@ -# ----------------------------------------------------------------------------- -# Predbat Home Battery System -# Copyright Trefor Southwell 2024 - All Rights Reserved -# This application maybe used for personal use only and not for commercial use -# ----------------------------------------------------------------------------- +""" +Battery Prediction app +see Readme for information +""" + +import copy +import os +import re +import time +import math +from datetime import datetime, timedelta +import hashlib +import traceback + # fmt off # pylint: disable=consider-using-f-string # pylint: disable=line-too-long # pylint: disable=attribute-defined-outside-init -import copy -import os -import re -import time -import math -from datetime import datetime, timedelta -import hashlib -import traceback +# Import AppDaemon or our standalone wrapper +try: + import adbase as ad + import appdaemon.plugins.hass.hassapi as hass +except: + import hass as hass + +import pytz +import requests +import yaml +from multiprocessing import Pool, cpu_count, set_start_method +import asyncio +from aiohttp import web, ClientSession, WSMsgType +import json + +# Only assign globals once to avoid re-creating them with processes are forked +if not "PRED_GLOBAL" in globals(): + PRED_GLOBAL = {} + +THIS_VERSION = "v7.22.5" +PREDBAT_FILES = ["predbat.py"] +TIME_FORMAT = "%Y-%m-%dT%H:%M:%S%z" +TIME_FORMAT_SECONDS = "%Y-%m-%dT%H:%M:%S.%f%z" +TIME_FORMAT_SOLCAST = "%Y-%m-%dT%H:%M:%S.%f0%z" # 2024-05-31T18:00:00.0000000Z +TIME_FORMAT_OCTOPUS = "%Y-%m-%d %H:%M:%S%z" +TIME_FORMAT_SOLIS = "%Y-%m-%d %H:%M:%S" +PREDICT_STEP = 5 +RUN_EVERY = 5 +CONFIG_ROOTS = ["/config", "/conf", "/homeassistant", "./"] +TIME_FORMAT_HA = "%Y-%m-%dT%H:%M:%S%z" +TIMEOUT = 60 * 5 +CONFIG_REFRESH_PERIOD = 60 * 8 + +# 240v x 100 amps x 3 phases / 1000 to kW / 60 minutes in an hour is the maximum kWh in a 1 minute period +MAX_INCREMENT = 240 * 100 * 3 / 1000 / 60 +MINUTE_WATT = 60 * 1000 + +SIMULATE = False # Debug option, when set don't write to entities but simulate each 30 min period +SIMULATE_LENGTH = 23 * 60 # How many periods to simulate, set to 0 for just current +INVERTER_TEST = False # Run inverter control self test + +# Create an array of times in the day in 5-minute intervals +BASE_TIME = datetime.strptime("00:00:00", "%H:%M:%S") +OPTIONS_TIME = [((BASE_TIME + timedelta(seconds=minute * 60)).strftime("%H:%M:%S")) for minute in range(0, 24 * 60, 5)] + +# Inverter modes +PREDBAT_MODE_OPTIONS = ["Monitor", "Control SOC only", "Control charge", "Control charge & discharge"] +PREDBAT_MODE_MONITOR = 0 +PREDBAT_MODE_CONTROL_SOC = 1 +PREDBAT_MODE_CONTROL_CHARGE = 2 +PREDBAT_MODE_CONTROL_CHARGEDISCHARGE = 3 + +# Predbat update options +PREDBAT_UPDATE_OPTIONS = [f"{THIS_VERSION} Loading..."] +PREDBAT_SAVE_RESTORE = ["save current", "restore default"] + +# Configuration options inside HA +CONFIG_ITEMS = [ + { + "name": "version", + "friendly_name": "Predbat Core Update", + "type": "update", + "title": "Predbat", + "installed_version": THIS_VERSION, + "release_url": f"https://github.com/springfall2008/batpred/releases/tag/{THIS_VERSION}", + "entity_picture": "https://user-images.githubusercontent.com/48591903/249456079-e98a0720-d2cf-4b71-94ab-97fe09b3cee1.png", + "restore": False, + "default": False, + }, + { + "name": "expert_mode", + "friendly_name": "Expert Mode", + "type": "switch", + "default": False, + }, + { + "name": "active", + "friendly_name": "Predbat Active", + "type": "switch", + "default": False, + "restore": False, + }, + { + "name": "pv_metric10_weight", + "friendly_name": "Metric 10 Weight", + "type": "input_number", + "min": 0, + "max": 1.0, + "step": 0.01, + "unit": "*", + "icon": "mdi:multiplication", + "default": 0.15, + }, + { + "name": "pv_scaling", + "friendly_name": "PV Scaling", + "type": "input_number", + "min": 0, + "max": 2.0, + "step": 0.01, + "unit": "*", + "icon": "mdi:multiplication", + "default": 1.0, + }, + { + "name": "load_scaling", + "friendly_name": "Load Scaling", + "type": "input_number", + "min": 0, + "max": 2.0, + "step": 0.01, + "unit": "*", + "icon": "mdi:multiplication", + "default": 1.0, + }, + { + "name": "load_scaling10", + "friendly_name": "Load Scaling PV10%", + "type": "input_number", + "min": 0, + "max": 2.0, + "step": 0.01, + "unit": "*", + "icon": "mdi:multiplication", + "default": 1.1, + }, + { + "name": "load_scaling_saving", + "friendly_name": "Load Scaling for saving sessions", + "type": "input_number", + "min": 0, + "max": 2.0, + "step": 0.01, + "unit": "*", + "icon": "mdi:multiplication", + "default": 1.0, + }, + { + "name": "battery_rate_max_scaling", + "friendly_name": "Battery rate max scaling charge", + "type": "input_number", + "min": 0, + "max": 1.0, + "step": 0.01, + "unit": "*", + "icon": "mdi:multiplication", + "default": 1.0, + }, + { + "name": "battery_rate_max_scaling_discharge", + "friendly_name": "Battery rate max scaling discharge", + "type": "input_number", + "min": 0, + "max": 1.0, + "step": 0.01, + "unit": "*", + "icon": "mdi:multiplication", + "default": 1.0, + }, + { + "name": "battery_loss", + "friendly_name": "Battery loss charge ", + "type": "input_number", + "min": 0, + "max": 1.0, + "step": 0.01, + "unit": "*", + "icon": "mdi:call-split", + "default": 0.03, + }, + { + "name": "battery_loss_discharge", + "friendly_name": "Battery loss discharge", + "type": "input_number", + "min": 0, + "max": 1.0, + "step": 0.01, + "unit": "*", + "icon": "mdi:call-split", + "default": 0.03, + }, + { + "name": "inverter_loss", + "friendly_name": "Inverter Loss", + "type": "input_number", + "min": 0, + "max": 1.0, + "step": 0.01, + "unit": "*", + "icon": "mdi:call-split", + "default": 0.04, + }, + { + "name": "inverter_hybrid", + "friendly_name": "Inverter Hybrid", + "type": "switch", + "default": True, + }, + { + "name": "inverter_soc_reset", + "friendly_name": "Inverter SOC Reset", + "type": "switch", + "enable": "expert_mode", + "default": True, + }, + { + "name": "inverter_set_charge_before", + "friendly_name": "Inverter Set charge window before start", + "type": "switch", + "enable": "expert_mode", + "default": True, + }, + { + "name": "battery_capacity_nominal", + "friendly_name": "Use the Battery Capacity Nominal size", + "type": "switch", + "enable": "expert_mode", + "default": False, + }, + { + "name": "car_charging_energy_scale", + "friendly_name": "Car charging energy scale", + "type": "input_number", + "min": 0, + "max": 1.0, + "step": 0.01, + "unit": "*", + "icon": "mdi:multiplication", + "default": 1.0, + }, + { + "name": "car_charging_threshold", + "friendly_name": "Car charging threshold", + "type": "input_number", + "min": 4, + "max": 8.5, + "step": 0.10, + "unit": "kW", + "icon": "mdi:ev-station", + "default": 6.0, + }, + { + "name": "car_charging_rate", + "friendly_name": "Car charging rate", + "type": "input_number", + "min": 1, + "max": 8.5, + "step": 0.10, + "unit": "kW", + "icon": "mdi:ev-station", + "default": 7.4, + }, + { + "name": "car_charging_loss", + "friendly_name": "Car charging loss", + "type": "input_number", + "min": 0, + "max": 1.0, + "step": 0.01, + "unit": "*", + "icon": "mdi:call-split", + "default": 0.08, + }, + { + "name": "best_soc_min", + "friendly_name": "Best SOC Min", + "type": "input_number", + "min": 0, + "max": 30.0, + "step": 0.10, + "unit": "kWh", + "icon": "mdi:battery-50", + "enable": "expert_mode", + "default": 0.0, + }, + { + "name": "best_soc_max", + "friendly_name": "Best SOC Max", + "type": "input_number", + "min": 0, + "max": 30.0, + "step": 0.10, + "unit": "kWh", + "icon": "mdi:battery-50", + "enable": "expert_mode", + "default": 0.0, + }, + { + "name": "best_soc_keep", + "friendly_name": "Best SOC Keep", + "type": "input_number", + "min": 0, + "max": 30.0, + "step": 0.10, + "unit": "kWh", + "icon": "mdi:battery-50", + "default": 0.5, + }, + { + "name": "metric_min_improvement", + "friendly_name": "Metric Min Improvement", + "type": "input_number", + "min": -50, + "max": 50.0, + "step": 0.1, + "unit": "p", + "icon": "mdi:currency-usd", + "enable": "expert_mode", + "default": 0.0, + }, + { + "name": "metric_min_improvement_discharge", + "friendly_name": "Metric Min Improvement Discharge", + "type": "input_number", + "min": -50, + "max": 50.0, + "step": 0.1, + "unit": "p", + "icon": "mdi:currency-usd", + "enable": "expert_mode", + "default": 0.1, + }, + { + "name": "metric_battery_cycle", + "friendly_name": "Metric Battery Cycle Cost", + "type": "input_number", + "min": -50, + "max": 50.0, + "step": 0.1, + "unit": "p/kWh", + "icon": "mdi:currency-usd", + "enable": "expert_mode", + "default": 0.5, + }, + { + "name": "metric_battery_value_scaling", + "friendly_name": "Metric Battery Value Scaling", + "type": "input_number", + "min": 0, + "max": 2.0, + "step": 0.1, + "unit": "*", + "icon": "mdi:multiplication", + "enable": "expert_mode", + "default": 1.0, + }, + { + "name": "metric_future_rate_offset_import", + "friendly_name": "Metric Future Rate Offset Import", + "type": "input_number", + "min": -50, + "max": 50.0, + "step": 0.1, + "unit": "p/kWh", + "icon": "mdi:currency-usd", + "enable": "expert_mode", + "default": 0.0, + }, + { + "name": "metric_future_rate_offset_export", + "friendly_name": "Metric Future Rate Offset Export", + "type": "input_number", + "min": -50, + "max": 50.0, + "step": 0.1, + "unit": "p/kWh", + "icon": "mdi:currency-usd", + "enable": "expert_mode", + "default": 0.0, + }, + { + "name": "metric_inday_adjust_damping", + "friendly_name": "In-day adjustment damping factor", + "type": "input_number", + "min": 0.5, + "max": 2.0, + "step": 0.05, + "unit": "*", + "icon": "mdi:call-split", + "enable": "expert_mode", + "default": 0.95, + }, + { + "name": "metric_cloud_enable", + "friendly_name": "Enable Cloud Model", + "type": "switch", + "default": True, + "enable": "expert_mode", + }, + { + "name": "metric_load_divergence_enable", + "friendly_name": "Enable Load Divergence Model", + "type": "switch", + "default": True, + "enable": "expert_mode", + }, + { + "name": "set_reserve_min", + "friendly_name": "Set Reserve Min", + "type": "input_number", + "min": 4, + "max": 100, + "step": 1, + "unit": "%", + "icon": "mdi:percent", + "default": 4.0, + "reset_inverter": True, + }, + { + "name": "rate_low_threshold", + "friendly_name": "Rate Low Threshold", + "type": "input_number", + "min": 0.00, + "max": 2.00, + "step": 0.05, + "unit": "*", + "icon": "mdi:multiplication", + "enable": "expert_mode", + "default": 0.0, + }, + { + "name": "rate_high_threshold", + "friendly_name": "Rate High Threshold", + "type": "input_number", + "min": 0.00, + "max": 2.00, + "step": 0.05, + "unit": "*", + "icon": "mdi:multiplication", + "enable": "expert_mode", + "default": 0.0, + }, + { + "name": "combine_rate_threshold", + "friendly_name": "Combine Rate Threshold", + "type": "input_number", + "min": 0, + "max": 5.0, + "step": 0.1, + "unit": "p", + "icon": "mdi:table-merge-cells", + "enable": "expert_mode", + "default": 0.0, + }, + { + "name": "car_charging_hold", + "friendly_name": "Car charging hold", + "type": "switch", + "default": True, + "reset_inverter": True, + }, + { + "name": "car_charging_manual_soc", + "friendly_name": "Car charging manual SOC", + "type": "switch", + "default": False, + }, + { + "name": "car_charging_manual_soc_kwh", + "friendly_name": "Car manual SOC kWh", + "type": "input_number", + "min": 0, + "max": 100, + "step": 0.01, + "unit": "kWh", + "icon": "mdi:ev-station", + "enable": "car_charging_manual_soc", + "default": 0.0, + "restore": False, + }, + { + "name": "octopus_intelligent_charging", + "friendly_name": "Octopus Intelligent Charging", + "type": "switch", + "default": True, + }, + { + "name": "octopus_intelligent_ignore_unplugged", + "friendly_name": "Ignore Intelligent slots when car is unplugged", + "type": "switch", + "default": False, + "enable": "expert_mode", + }, + { + "name": "car_charging_plan_smart", + "friendly_name": "Car Charging Plan Smart", + "type": "switch", + "default": False, + }, + { + "name": "car_charging_plan_max_price", + "friendly_name": "Car Charging Plan max price", + "type": "input_number", + "min": -99, + "max": 99, + "step": 1, + "unit": "p", + "icon": "mdi:ev-station", + "default": 0, + }, + { + "name": "car_charging_from_battery", + "friendly_name": "Allow car to charge from battery", + "type": "switch", + "default": False, + "reset_inverter": True, + }, + { + "name": "calculate_discharge_oncharge", + "friendly_name": "Calculate Discharge on charge slots", + "type": "switch", + "enable": "expert_mode", + "default": True, + }, + { + "name": "calculate_second_pass", + "friendly_name": "Calculate full second pass (slower)", + "type": "switch", + "enable": "expert_mode", + "default": False, + }, + { + "name": "calculate_tweak_plan", + "friendly_name": "Calculate tweak second pass", + "type": "switch", + "enable": "expert_mode", + "default": False, + }, + { + "name": "calculate_secondary_order", + "friendly_name": "Calculate secondary order slots", + "type": "switch", + "enable": "expert_mode", + "default": True, + }, + { + "name": "calculate_inday_adjustment", + "friendly_name": "Calculate in-day adjustment", + "type": "switch", + "enable": "expert_mode", + "default": True, + }, + { + "name": "calculate_plan_every", + "friendly_name": "Calculate plan every N minutes", + "type": "input_number", + "min": 5, + "max": 60, + "step": 5, + "unit": "minutes", + "icon": "mdi:clock-end", + "enable": "expert_mode", + "default": 10, + }, + { + "name": "combine_charge_slots", + "friendly_name": "Combine Charge Slots", + "type": "switch", + "default": False, + }, + { + "name": "combine_discharge_slots", + "friendly_name": "Combine Discharge Slots", + "type": "switch", + "enable": "expert_mode", + "default": False, + }, + { + "name": "set_status_notify", + "friendly_name": "Set Status Notify", + "type": "switch", + "default": True, + }, + { + "name": "set_inverter_notify", + "friendly_name": "Set Inverter Notify", + "type": "switch", + "default": False, + }, + { + "name": "set_charge_freeze", + "friendly_name": "Set Charge Freeze", + "type": "switch", + "enable": "expert_mode", + "default": True, + "reset_inverter": True, + }, + { + "name": "set_charge_low_power", + "friendly_name": "Set Charge Low Power Mode", + "type": "switch", + "default": False, + "reset_inverter": True, + }, + { + "name": "set_reserve_enable", + "friendly_name": "Set Reserve Enable", + "type": "switch", + "enable": "expert_mode", + "default": True, + "reset_inverter": True, + }, + { + "name": "set_discharge_freeze_only", + "friendly_name": "Set Discharge Freeze Only", + "type": "switch", + "enable": "expert_mode", + "default": False, + "reset_inverter": True, + }, + { + "name": "set_discharge_during_charge", + "friendly_name": "Set Discharge During Charge", + "type": "switch", + "default": True, + }, + { + "name": "set_read_only", + "friendly_name": "Read Only mode", + "type": "switch", + "default": False, + "reset_inverter_force": True, + }, + { + "name": "balance_inverters_enable", + "friendly_name": "Balance Inverters Enable (Beta)", + "type": "switch", + "default": False, + }, + { + "name": "balance_inverters_charge", + "friendly_name": "Balance Inverters for charging", + "type": "switch", + "enable": "balance_inverters_enable", + "default": True, + }, + { + "name": "balance_inverters_discharge", + "friendly_name": "Balance Inverters for discharge", + "type": "switch", + "enable": "balance_inverters_enable", + "default": True, + }, + { + "name": "balance_inverters_crosscharge", + "friendly_name": "Balance Inverters for cross-charging", + "type": "switch", + "enable": "balance_inverters_enable", + "default": True, + }, + { + "name": "balance_inverters_threshold_charge", + "friendly_name": "Balance Inverters threshold charge", + "type": "input_number", + "min": 1, + "max": 20, + "step": 1, + "unit": "%", + "icon": "mdi:percent", + "enable": "balance_inverters_enable", + "default": 1.0, + }, + { + "name": "balance_inverters_threshold_discharge", + "friendly_name": "Balance Inverters threshold discharge", + "type": "input_number", + "min": 1, + "max": 20, + "step": 1, + "unit": "%", + "icon": "mdi:percent", + "enable": "balance_inverters_enable", + "default": 1.0, + }, + { + "name": "debug_enable", + "friendly_name": "Debug Enable", + "type": "switch", + "icon": "mdi:bug-outline", + "default": False, + }, + { + "name": "car_charging_plan_time", + "friendly_name": "Car charging planned ready time", + "type": "select", + "options": OPTIONS_TIME, + "icon": "mdi:clock-end", + "default": "07:00:00", + }, + { + "name": "mode", + "friendly_name": "Predbat mode", + "type": "select", + "options": PREDBAT_MODE_OPTIONS, + "icon": "mdi:state-machine", + "default": PREDBAT_MODE_OPTIONS[PREDBAT_MODE_CONTROL_CHARGEDISCHARGE], + "reset_inverter_force": True, + }, + { + "name": "update", + "friendly_name": "Predbat update", + "type": "select", + "options": PREDBAT_UPDATE_OPTIONS, + "icon": "mdi:state-machine", + "default": None, + "restore": False, + }, + { + "name": "manual_charge", + "friendly_name": "Manual force charge", + "type": "select", + "options": ["off"], + "icon": "mdi:state-machine", + "default": "off", + "restore": False, + "manual": True, + }, + { + "name": "manual_discharge", + "friendly_name": "Manual force discharge", + "type": "select", + "options": ["off"], + "icon": "mdi:state-machine", + "default": "off", + "restore": False, + "manual": True, + }, + { + "name": "manual_idle", + "friendly_name": "Manual force idle", + "type": "select", + "options": ["off"], + "icon": "mdi:state-machine", + "default": "off", + "restore": False, + "manual": True, + }, + { + "name": "manual_freeze_charge", + "friendly_name": "Manual force charge freeze", + "type": "select", + "options": ["off"], + "icon": "mdi:state-machine", + "default": "off", + "restore": False, + "manual": True, + }, + { + "name": "manual_freeze_discharge", + "friendly_name": "Manual force discharge freeze", + "type": "select", + "options": ["off"], + "icon": "mdi:state-machine", + "default": "off", + "restore": False, + "manual": True, + }, + { + "name": "saverestore", + "friendly_name": "Save/restore settings", + "type": "select", + "options": PREDBAT_SAVE_RESTORE, + "icon": "mdi:state-machine", + "default": "", + "restore": False, + }, + { + "name": "auto_update", + "friendly_name": "Predbat automatic update enable", + "type": "switch", + "default": False, + }, + { + "name": "load_filter_modal", + "friendly_name": "Apply modal filter historical load", + "type": "switch", + "enable": "expert_mode", + "default": True, + }, + { + "name": "iboost_enable", + "friendly_name": "iBoost enable", + "type": "switch", + "default": False, + }, + { + "name": "carbon_enable", + "friendly_name": "Carbon enable", + "type": "switch", + "default": False, + }, + { + "name": "carbon_metric", + "friendly_name": "Carbon Metric", + "type": "input_number", + "min": 0, + "max": 500, + "step": 1, + "unit": "p/Kg", + "icon": "mdi:molecule-co2", + "default": 0, + "enable": "carbon_enable", + }, + { + "name": "iboost_solar", + "friendly_name": "iBoost on solar power", + "type": "switch", + "default": True, + }, + { + "name": "iboost_gas", + "friendly_name": "iBoost when electricity cheaper than gas", + "type": "switch", + "default": False, + }, + { + "name": "iboost_charging", + "friendly_name": "iBoost when battery charging", + "type": "switch", + "default": False, + }, + { + "name": "iboost_gas_scale", + "friendly_name": "iBoost gas price scaling", + "type": "input_number", + "min": 0, + "max": 2.0, + "step": 0.1, + "unit": "*", + "icon": "mdi:multiplication", + "enable": "iboost_enable", + "default": 1.0, + }, + { + "name": "iboost_max_energy", + "friendly_name": "iBoost max energy", + "type": "input_number", + "min": 0, + "max": 20, + "step": 0.1, + "unit": "kWh", + "enable": "iboost_enable", + "default": 3.0, + }, + { + "name": "iboost_today", + "friendly_name": "iBoost today", + "type": "input_number", + "min": 0, + "max": 5, + "step": 0.1, + "unit": "kWh", + "enable": "iboost_enable", + "default": 0.0, + }, + { + "name": "iboost_max_power", + "friendly_name": "iBoost max power", + "type": "input_number", + "min": 0, + "max": 3500, + "step": 100, + "unit": "W", + "enable": "iboost_enable", + "default": 2400, + "restore": False, + }, + { + "name": "iboost_min_power", + "friendly_name": "iBoost min power", + "type": "input_number", + "min": 0, + "max": 3500, + "step": 100, + "unit": "W", + "enable": "iboost_enable", + "default": 500, + }, + { + "name": "iboost_min_soc", + "friendly_name": "iBoost min soc", + "type": "input_number", + "min": 0, + "max": 100, + "step": 5, + "unit": "%", + "icon": "mdi:percent", + "enable": "iboost_enable", + "default": 0.0, + }, + { + "name": "holiday_days_left", + "friendly_name": "Holiday days left", + "type": "input_number", + "min": 0, + "max": 28, + "step": 1, + "unit": "days", + "icon": "mdi:clock-end", + "default": 0, + "restore": False, + }, + { + "name": "forecast_plan_hours", + "friendly_name": "Plan forecast hours", + "type": "input_number", + "min": 8, + "max": 96, + "step": 1, + "unit": "hours", + "icon": "mdi:clock-end", + "enable": "expert_mode", + "default": 24, + }, + { + "name": "plan_debug", + "friendly_name": "HTML Plan Debug", + "type": "switch", + "default": False, + "enable": "expert_mode", + }, +] + +""" +GE Inverters are the default but not all inverters have the same parameters so this constant +maps the parameters that are different between brands. + +The approach is to attempt to mimic the GE model with dummy entities in HA so that predbat GE +code can be used with minimal modification. +""" +INVERTER_DEF = { + "GE": { + "name": "GivEnergy", + "has_rest_api": True, + "has_mqtt_api": False, + "has_service_api": False, + "output_charge_control": "power", + "has_charge_enable_time": True, + "has_discharge_enable_time": False, + "has_target_soc": True, + "has_reserve_soc": True, + "has_timed_pause": True, + "charge_time_format": "HH:MM:SS", + "charge_time_entity_is_option": True, + "soc_units": "kWh", + "num_load_entities": 1, + "has_ge_inverter_mode": True, + "time_button_press": False, + "clock_time_format": "%H:%M:%S", + "write_and_poll_sleep": 10, + "has_time_window": True, + "support_charge_freeze": True, + "support_discharge_freeze": True, + "has_idle_time": False, + "can_span_midnight": True, + }, + "GEC": { + "name": "GivEnergy Cloud", + "has_rest_api": False, + "has_mqtt_api": False, + "has_service_api": False, + "output_charge_control": "power", + "has_charge_enable_time": True, + "has_discharge_enable_time": True, + "has_target_soc": True, + "has_reserve_soc": True, + "has_timed_pause": True, + "charge_time_format": "HH:MM:SS", + "charge_time_entity_is_option": True, + "soc_units": "kWh", + "num_load_entities": 1, + "has_ge_inverter_mode": False, + "time_button_press": False, + "clock_time_format": "%H:%M:%S", + "write_and_poll_sleep": 10, + "has_time_window": True, + "support_charge_freeze": True, + "support_discharge_freeze": True, + "has_idle_time": False, + "can_span_midnight": True, + }, + "GEE": { + "name": "GivEnergy EMC", + "has_rest_api": False, + "has_mqtt_api": False, + "has_service_api": False, + "output_charge_control": "power", + "has_charge_enable_time": False, + "has_discharge_enable_time": False, + "has_target_soc": True, + "has_reserve_soc": True, + "has_timed_pause": False, + "charge_time_format": "HH:MM:SS", + "charge_time_entity_is_option": True, + "soc_units": "kWh", + "num_load_entities": 1, + "has_ge_inverter_mode": False, + "time_button_press": False, + "clock_time_format": "%H:%M:%S", + "write_and_poll_sleep": 10, + "has_time_window": True, + "support_charge_freeze": True, + "support_discharge_freeze": False, + "has_idle_time": True, + "can_span_midnight": False, + }, + "GS": { + "name": "Ginlong Solis", + "has_rest_api": False, + "has_mqtt_api": False, + "has_service_api": False, + "output_charge_control": "current", + "has_charge_enable_time": False, + "has_discharge_enable_time": False, + "has_target_soc": False, + "has_reserve_soc": False, + "has_timed_pause": False, + "charge_time_format": "H M", + "charge_time_entity_is_option": False, + "soc_units": "%", + "num_load_entities": 2, + "has_ge_inverter_mode": False, + "time_button_press": True, + "clock_time_format": "%Y-%m-%d %H:%M:%S", + "write_and_poll_sleep": 2, + "has_time_window": True, + "support_charge_freeze": False, + "support_discharge_freeze": False, + "has_idle_time": False, + "can_span_midnight": True, + }, + "SE": { + "name": "SolarEdge", + "has_rest_api": False, + "has_mqtt_api": False, + "has_service_api": True, + "output_charge_control": "power", + "has_charge_enable_time": False, + "has_discharge_enable_time": False, + "has_target_soc": False, + "has_reserve_soc": False, + "has_timed_pause": False, + "charge_time_format": "S", + "charge_time_entity_is_option": False, + "soc_units": "%", + "num_load_entities": 1, + "has_ge_inverter_mode": False, + "time_button_press": False, + "clock_time_format": "%Y-%m-%d %H:%M:%S", + "write_and_poll_sleep": 2, + "has_time_window": False, + "support_charge_freeze": False, + "support_discharge_freeze": False, + "has_idle_time": False, + "can_span_midnight": True, + }, + "SX4": { + "name": "Solax Gen4 (Modbus Power Control)", + "has_rest_api": False, + "has_mqtt_api": False, + "has_service_api": False, + "output_charge_control": "power", + "has_charge_enable_time": False, + "has_discharge_enable_time": False, + "has_target_soc": False, + "has_reserve_soc": False, + "has_timed_pause": False, + "charge_time_format": "S", + "charge_time_entity_is_option": False, + "soc_units": "%", + "num_load_entities": 1, + "has_ge_inverter_mode": False, + "time_button_press": True, + "clock_time_format": "%Y-%m-%d %H:%M:%S", + "write_and_poll_sleep": 2, + "has_time_window": False, + "support_charge_freeze": False, + "support_discharge_freeze": False, + "has_idle_time": False, + "can_span_midnight": True, + }, + "SF": { + "name": "Sofar HYD", + "has_rest_api": False, + "has_mqtt_api": True, + "has_service_api": False, + "output_charge_control": "none", + "has_charge_enable_time": False, + "has_discharge_enable_time": False, + "has_target_soc": False, + "has_reserve_soc": False, + "has_timed_pause": False, + "charge_time_format": "S", + "charge_time_entity_is_option": False, + "soc_units": "%", + "num_load_entities": 1, + "has_ge_inverter_mode": False, + "time_button_press": False, + "clock_time_format": "%Y-%m-%d %H:%M:%S", + "write_and_poll_sleep": 2, + "has_time_window": False, + "support_charge_freeze": False, + "support_discharge_freeze": False, + "has_idle_time": False, + "can_span_midnight": True, + }, + "HU": { + "name": "Huawei Solar", + "has_rest_api": False, + "has_mqtt_api": False, + "has_service_api": True, + "output_charge_control": "power", + "has_charge_enable_time": False, + "has_discharge_enable_time": False, + "has_target_soc": False, + "has_reserve_soc": False, + "has_timed_pause": False, + "charge_time_format": "S", + "charge_time_entity_is_option": False, + "soc_units": "%", + "num_load_entities": 1, + "has_ge_inverter_mode": False, + "time_button_press": False, + "clock_time_format": "%Y-%m-%d %H:%M:%S", + "write_and_poll_sleep": 2, + "has_time_window": False, + "support_charge_freeze": False, + "support_discharge_freeze": False, + "has_idle_time": False, + "can_span_midnight": True, + }, + "SK": { + "name": "Sunsynk", + "has_rest_api": False, + "has_mqtt_api": False, + "has_service_api": True, + "output_charge_control": "current", + "current_dp": 0, + "has_charge_enable_time": False, + "has_discharge_enable_time": False, + "has_target_soc": True, + "has_reserve_soc": False, + "has_timed_pause": False, + "charge_time_format": "S", + "charge_time_entity_is_option": False, + "soc_units": "%", + "num_load_entities": 1, + "has_ge_inverter_mode": False, + "time_button_press": False, + "clock_time_format": "%Y-%m-%d %H:%M:%S", + "write_and_poll_sleep": 5, + "has_time_window": False, + "support_charge_freeze": False, + "support_discharge_freeze": False, + "has_idle_time": False, + "can_span_midnight": True, + }, +} + +# Control modes for Solax inverters +SOLAX_SOLIS_MODES = { + "Selfuse - No Grid Charging": 1, + "Timed Charge/Discharge - No Grid Charging": 3, + "Backup/Reserve - No Grid Charging": 17, + "Selfuse": 33, + "Timed Charge/Discharge": 35, + "Off-Grid Mode": 37, + "Battery Awaken": 41, + "Battery Awaken + Timed Charge/Discharge": 43, + "Backup/Reserve - No Timed Charge/Discharge": 49, + "Backup/Reserve": 51, + "Feed-in priority - No Grid Charging": 64, + "Feed-in priority - No Timed Charge/Discharge": 96, + "Feed-in priority": 98, +} +# New modes are from 2024.03.2 controlled with solax_modbus_new in apps.yaml +SOLAX_SOLIS_MODES_NEW = { + "Self-Use - No Grid Charging": 1, + "Timed Charge/Discharge - No Grid Charging": 3, + "Backup/Reserve - No Grid Charging": 17, + "Self-Use - No Timed Charge/Discharge": 33, + "Self-Use": 35, + "Off-Grid Mode": 37, + "Battery Awaken": 41, + "Battery Awaken + Timed Charge/Discharge": 43, + "Backup/Reserve - No Timed Charge/Discharge": 49, + "Backup/Reserve": 51, + "Feed-in priority - No Grid Charging": 64, + "Feed-in priority - No Timed Charge/Discharge": 96, + "Feed-in priority": 98, +} + + +def remove_intersecting_windows(charge_limit_best, charge_window_best, discharge_limit_best, discharge_window_best): + """ + Filters and removes intersecting charge windows + """ + clip_again = True + + # For each charge window + while clip_again: + clip_again = False + new_limit_best = [] + new_window_best = [] + for window_n in range(len(charge_limit_best)): + window = charge_window_best[window_n] + start = window["start"] + end = window["end"] + average = window["average"] + limit = charge_limit_best[window_n] + clipped = False + + # For each discharge window + for dwindow_n in range(len(discharge_limit_best)): + dwindow = discharge_window_best[dwindow_n] + dlimit = discharge_limit_best[dwindow_n] + dstart = dwindow["start"] + dend = dwindow["end"] + + # Overlapping window with enabled discharge? + if (limit > 0.0) and (dlimit < 100.0) and (dstart < end) and (dend >= start): + if dstart <= start: + if start != dend: + start = dend + clipped = True + elif dend >= end: + if end != dstart: + end = dstart + clipped = True + else: + # Two segments + if (dstart - start) >= 5: + new_window = {} + new_window["start"] = start + new_window["end"] = dstart + new_window["average"] = average + new_window_best.append(new_window) + new_limit_best.append(limit) + start = dend + clipped = True + if (end - start) >= 5: + clip_again = True + + if not clipped or ((end - start) >= 5): + new_window = {} + new_window["start"] = start + new_window["end"] = end + new_window["average"] = average + new_window_best.append(new_window) + new_limit_best.append(limit) + + if clip_again: + charge_window_best = new_window_best.copy() + charge_limit_best = new_limit_best.copy() + + return new_limit_best, new_window_best + + +def calc_percent_limit(charge_limit, soc_max): + """ + Calculate a charge limit in percent + """ + if isinstance(charge_limit, list): + if soc_max <= 0: + return [0 for i in range(len(charge_limit))] + else: + return [min(int((float(charge_limit[i]) / soc_max * 100.0) + 0.5), 100) for i in range(len(charge_limit))] + else: + if soc_max <= 0: + return 0 + else: + return min(int((float(charge_limit) / soc_max * 100.0) + 0.5), 100) + + +def get_charge_rate_curve(model, soc, charge_rate_setting): + """ + Compute true charging rate from SOC and charge rate setting + """ + soc_percent = calc_percent_limit(soc, model.soc_max) + max_charge_rate = model.battery_rate_max_charge * model.battery_charge_power_curve.get(soc_percent, 1.0) * model.battery_rate_max_scaling + return max(min(charge_rate_setting, max_charge_rate), model.battery_rate_min) + + +def get_discharge_rate_curve(model, soc, discharge_rate_setting): + """ + Compute true discharging rate from SOC and charge rate setting + """ + soc_percent = calc_percent_limit(soc, model.soc_max) + max_discharge_rate = model.battery_rate_max_discharge * model.battery_discharge_power_curve.get(soc_percent, 1.0) * model.battery_rate_max_scaling_discharge + return max(min(discharge_rate_setting, max_discharge_rate), model.battery_rate_min) + + +def find_charge_rate(model, minutes_now, soc, window, target_soc, max_rate, quiet=True): + """ + Find the lowest charge rate that fits the charge slow + """ + margin = 10 + if model.set_charge_low_power: + minutes_left = window["end"] - minutes_now - margin + + # If we don't have enough minutes left go to max + if minutes_left < 0: + return max_rate + + # If we already have reached target go back to max + if soc >= target_soc: + return max_rate + + # Work out the charge left in kw + charge_left = target_soc - soc + + # If we can never hit the target then go to max + if max_rate * minutes_left < charge_left: + return max_rate + + # What's the lowest we could go? + min_rate = charge_left / minutes_left + + # Apply the curve at each rate to pick one that works + rate_w = max_rate * MINUTE_WATT + best_rate = max_rate + while rate_w >= 400: + rate = rate_w / MINUTE_WATT + if rate >= min_rate: + charge_now = soc + minute = 0 + for minute in range(0, minutes_left, PREDICT_STEP): + rate_scale = get_charge_rate_curve(model, charge_now, rate) + charge_amount = rate_scale * PREDICT_STEP * model.battery_loss + charge_now += charge_amount + if charge_now >= target_soc: + best_rate = rate + break + rate_w -= 125.0 + return best_rate + else: + return max_rate + + +""" +Used to mimic threads when they are disabled +""" + + +class DummyThread: + def __init__(self, result): + """ + Store the data into the class + """ + self.result = result + + def get(self): + """ + Return the result + """ + return self.result + + +def wrapped_run_prediction_single(charge_limit, charge_window, discharge_window, discharge_limits, pv10, end_record, step): + global PRED_GLOBAL + pred = Prediction() + pred.__dict__ = PRED_GLOBAL["dict"].copy() + return pred.thread_run_prediction_single(charge_limit, charge_window, discharge_window, discharge_limits, pv10, end_record, step) + + +def wrapped_run_prediction_charge(try_soc, window_n, charge_limit, charge_window, discharge_window, discharge_limits, pv10, all_n, end_record): + global PRED_GLOBAL + pred = Prediction() + pred.__dict__ = PRED_GLOBAL["dict"].copy() + return pred.thread_run_prediction_charge(try_soc, window_n, charge_limit, charge_window, discharge_window, discharge_limits, pv10, all_n, end_record) + + +def wrapped_run_prediction_discharge(this_discharge_limit, start, window_n, charge_limit, charge_window, discharge_window, discharge_limits, pv10, all_n, end_record): + global PRED_GLOBAL + pred = Prediction() + pred.__dict__ = PRED_GLOBAL["dict"].copy() + return pred.thread_run_prediction_discharge(this_discharge_limit, start, window_n, charge_limit, charge_window, discharge_window, discharge_limits, pv10, all_n, end_record) + + +class Prediction: + """ + Class to hold prediction input and output data and the run function + """ + + def __init__(self, base=None, pv_forecast_minute_step=None, pv_forecast_minute10_step=None, load_minutes_step=None, load_minutes_step10=None): + global PRED_GLOBAL + if base: + self.minutes_now = base.minutes_now + self.log = base.log + self.forecast_minutes = base.forecast_minutes + self.midnight_utc = base.midnight_utc + self.soc_kw = base.soc_kw + self.soc_max = base.soc_max + self.export_today_now = base.export_today_now + self.import_today_now = base.import_today_now + self.load_minutes_now = base.load_minutes_now + self.pv_today_now = base.pv_today_now + self.iboost_today = base.iboost_today + self.charge_rate_now = base.charge_rate_now + self.discharge_rate_now = base.discharge_rate_now + self.cost_today_sofar = base.cost_today_sofar + self.carbon_today_sofar = base.carbon_today_sofar + self.debug_enable = base.debug_enable + self.num_cars = base.num_cars + self.car_charging_soc = base.car_charging_soc + self.car_charging_soc_next = base.car_charging_soc_next + self.car_charging_loss = base.car_charging_loss + self.reserve = base.reserve + self.metric_standing_charge = base.metric_standing_charge + self.set_charge_freeze = base.set_charge_freeze + self.set_reserve_enable = base.set_reserve_enable + self.set_discharge_freeze = base.set_discharge_freeze + self.set_discharge_freeze_only = base.set_discharge_freeze_only + self.set_discharge_during_charge = base.set_discharge_during_charge + self.set_read_only = base.set_read_only + self.set_charge_low_power = base.set_charge_low_power + self.car_charging_slots = base.car_charging_slots + self.car_charging_limit = base.car_charging_limit + self.car_charging_from_battery = base.car_charging_from_battery + self.iboost_enable = base.iboost_enable + self.carbon_enable = base.carbon_enable + self.iboost_next = base.iboost_next + self.iboost_max_energy = base.iboost_max_energy + self.iboost_gas = base.iboost_gas + self.iboost_gas_scale = base.iboost_gas_scale + self.iboost_max_power = base.iboost_max_power + self.iboost_min_power = base.iboost_min_power + self.iboost_min_soc = base.iboost_min_soc + self.iboost_solar = base.iboost_solar + self.iboost_charging = base.iboost_charging + self.iboost_running = base.iboost_running + self.inverter_loss = base.inverter_loss + self.inverter_hybrid = base.inverter_hybrid + self.inverter_limit = base.inverter_limit + self.export_limit = base.export_limit + self.battery_rate_min = base.battery_rate_min + self.battery_rate_max_charge = base.battery_rate_max_charge + self.battery_rate_max_discharge = base.battery_rate_max_discharge + self.battery_rate_max_charge_scaled = base.battery_rate_max_charge_scaled + self.battery_rate_max_discharge_scaled = base.battery_rate_max_discharge_scaled + self.battery_charge_power_curve = base.battery_charge_power_curve + self.battery_discharge_power_curve = base.battery_discharge_power_curve + self.battery_rate_max_scaling = base.battery_rate_max_scaling + self.battery_rate_max_scaling_discharge = base.battery_rate_max_scaling_discharge + self.battery_loss = base.battery_loss + self.battery_loss_discharge = base.battery_loss_discharge + self.best_soc_keep = base.best_soc_keep + self.best_soc_min = base.best_soc_min + self.car_charging_battery_size = base.car_charging_battery_size + self.rate_gas = base.rate_gas + self.rate_import = base.rate_import + self.rate_export = base.rate_export + self.pv_forecast_minute_step = pv_forecast_minute_step + self.pv_forecast_minute10_step = pv_forecast_minute10_step + self.load_minutes_step = load_minutes_step + self.load_minutes_step10 = load_minutes_step10 + self.carbon_intensity = base.carbon_intensity + + # Store this dictionary in global so we can reconstruct it in the thread without passing the data + PRED_GLOBAL["dict"] = self.__dict__.copy() + + def thread_run_prediction_single(self, charge_limit, charge_window, discharge_window, discharge_limits, pv10, end_record, step): + """ + Run single prediction in a thread + """ + cost, import_kwh_battery, import_kwh_house, export_kwh, soc_min, soc, soc_min_minute, battery_cycle, metric_keep, final_iboost, final_carbon_g = self.run_prediction( + charge_limit, charge_window, discharge_window, discharge_limits, pv10, end_record=end_record, step=step + ) + return (cost, import_kwh_battery, import_kwh_house, export_kwh, soc_min, soc, soc_min_minute, battery_cycle, metric_keep, final_iboost, final_carbon_g) + + def thread_run_prediction_charge(self, try_soc, window_n, charge_limit, charge_window, discharge_window, discharge_limits, pv10, all_n, end_record): + """ + Run prediction in a thread + """ + + try_charge_limit = charge_limit.copy() + if all_n: + for set_n in all_n: + try_charge_limit[set_n] = try_soc + else: + try_charge_limit[window_n] = try_soc + + cost, import_kwh_battery, import_kwh_house, export_kwh, soc_min, soc, soc_min_minute, battery_cycle, metric_keep, final_iboost, final_carbon_g = self.run_prediction( + try_charge_limit, charge_window, discharge_window, discharge_limits, pv10, end_record=end_record + ) + min_soc = 0 + max_soc = self.soc_max + if not all_n: + window = charge_window[window_n] + predict_minute_start = max(int((window["start"] - self.minutes_now) / 5) * 5, 0) + predict_minute_end = int((window["end"] - self.minutes_now) / 5) * 5 + if (predict_minute_start in self.predict_soc) and (predict_minute_end in self.predict_soc): + min_soc = min(self.predict_soc[predict_minute_start], self.predict_soc[predict_minute_end]) + if (predict_minute_start in self.predict_soc) and (predict_minute_end in self.predict_soc): + max_soc = max(self.predict_soc[predict_minute_start], self.predict_soc[predict_minute_end]) + + return ( + cost, + import_kwh_battery, + import_kwh_house, + export_kwh, + soc_min, + soc, + soc_min_minute, + battery_cycle, + metric_keep, + final_iboost, + final_carbon_g, + min_soc, + max_soc, + ) + + def thread_run_prediction_discharge(self, this_discharge_limit, start, window_n, charge_limit, charge_window, discharge_window, discharge_limits, pv10, all_n, end_record): + """ + Run prediction in a thread + """ + # Store try value into the window + if all_n: + for window_id in all_n: + discharge_limits[window_id] = this_discharge_limit + else: + discharge_limits[window_n] = this_discharge_limit + # Adjust start + window = discharge_window[window_n] + start = min(start, window["end"] - 5) + discharge_window[window_n]["start"] = start + + metricmid, import_kwh_battery, import_kwh_house, export_kwh, soc_min, soc, soc_min_minute, battery_cycle, metric_keep, final_iboost, final_carbon_g = self.run_prediction( + charge_limit, charge_window, discharge_window, discharge_limits, pv10, end_record=end_record + ) + return metricmid, import_kwh_battery, import_kwh_house, export_kwh, soc_min, soc, soc_min_minute, battery_cycle, metric_keep, final_iboost, final_carbon_g + + def find_charge_window_optimised(self, charge_windows): + """ + Takes in an array of charge windows + Returns a dictionary defining for each minute that is in the charge window will contain the window number + """ + charge_window_optimised = {} + for window_n in range(len(charge_windows)): + for minute in range(charge_windows[window_n]["start"], charge_windows[window_n]["end"], PREDICT_STEP): + charge_window_optimised[minute] = window_n + return charge_window_optimised + + def in_car_slot(self, minute): + """ + Is the given minute inside a car slot + """ + load_amount = [0 for car_n in range(self.num_cars)] + + for car_n in range(self.num_cars): + if self.car_charging_slots[car_n]: + for slot in self.car_charging_slots[car_n]: + start_minutes = slot["start"] + end_minutes = slot["end"] + kwh = slot["kwh"] + slot_minutes = end_minutes - start_minutes + slot_hours = slot_minutes / 60.0 + + # Return the load in that slot + if minute >= start_minutes and minute < end_minutes: + load_amount[car_n] = abs(kwh / slot_hours) + break + return load_amount + + def run_prediction(self, charge_limit, charge_window, discharge_window, discharge_limits, pv10, end_record, save=None, step=PREDICT_STEP): + """ + Run a prediction scenario given a charge limit, return the results + """ + + # Fetch data from globals, optimised away from class to avoid passing it between threads + if pv10: + pv_forecast_minute_step = self.pv_forecast_minute10_step + load_minutes_step = self.load_minutes_step10 + else: + pv_forecast_minute_step = self.pv_forecast_minute_step + load_minutes_step = self.load_minutes_step + rate_gas = self.rate_gas + rate_import = self.rate_import + rate_export = self.rate_export + + # Data structures creating during the prediction + self.predict_soc = {} + self.predict_soc_best = {} + self.predict_metric_best = {} + self.predict_iboost_best = {} + self.predict_carbon_best = {} + + predict_export = {} + predict_battery_power = {} + predict_battery_cycle = {} + predict_soc_time = {} + predict_car_soc_time = [{} for car_n in range(self.num_cars)] + predict_pv_power = {} + predict_state = {} + predict_grid_power = {} + predict_load_power = {} + predict_iboost = {} + predict_carbon_g = {} + minute = 0 + minute_left = self.forecast_minutes + soc = self.soc_kw + soc_min = self.soc_max + soc_min_minute = self.minutes_now + charge_has_run = False + charge_has_started = False + discharge_has_run = False + export_kwh = self.export_today_now + export_kwh_h0 = export_kwh + import_kwh = self.import_today_now + import_kwh_h0 = import_kwh + load_kwh = self.load_minutes_now + load_kwh_h0 = load_kwh + pv_kwh = self.pv_today_now + pv_kwh_h0 = pv_kwh + iboost_today_kwh = self.iboost_today + import_kwh_house = 0 + import_kwh_battery = 0 + carbon_g = self.carbon_today_sofar + battery_cycle = 0 + metric_keep = 0 + four_hour_rule = True + final_export_kwh = export_kwh + final_import_kwh = import_kwh + final_load_kwh = load_kwh + final_pv_kwh = pv_kwh + final_iboost_kwh = iboost_today_kwh + final_import_kwh_house = import_kwh_house + final_import_kwh_battery = import_kwh_battery + final_battery_cycle = battery_cycle + final_metric_keep = metric_keep + final_carbon_g = carbon_g + metric = self.cost_today_sofar + final_soc = soc + first_charge_soc = soc + prev_soc = soc + final_metric = metric + metric_time = {} + load_kwh_time = {} + pv_kwh_time = {} + export_kwh_time = {} + import_kwh_time = {} + record_time = {} + car_soc = self.car_charging_soc[:] + final_car_soc = car_soc[:] + charge_rate_now = self.charge_rate_now + discharge_rate_now = self.discharge_rate_now + battery_state = "-" + grid_state = "-" + first_charge = end_record + export_to_first_charge = 0 + + # Remove intersecting windows and optimise the data format of the charge/discharge window + charge_limit, charge_window = remove_intersecting_windows(charge_limit, charge_window, discharge_limits, discharge_window) + charge_window_optimised = self.find_charge_window_optimised(charge_window) + discharge_window_optimised = self.find_charge_window_optimised(discharge_window) + + # For the SOC calculation we need to stop 24 hours after the first charging window starts + # to avoid wrapping into the next day + record = True + + # Simulate each forward minute + while minute < self.forecast_minutes: + # Minute yesterday can wrap if days_previous is only 1 + minute_absolute = minute + self.minutes_now + minute_timestamp = self.midnight_utc + timedelta(seconds=60 * minute_absolute) + prev_soc = soc + reserve_expected = self.reserve + + # Once a force discharge is set the four hour rule is disabled + if four_hour_rule: + keep_minute_scaling = min((minute / (4 * 60)), 1.0) * 0.5 + else: + keep_minute_scaling = 0.5 + + # Find charge & discharge windows + charge_window_n = charge_window_optimised.get(minute_absolute, -1) + discharge_window_n = discharge_window_optimised.get(minute_absolute, -1) + + # Find charge limit + charge_limit_n = 0 + if charge_window_n >= 0: + charge_limit_n = charge_limit[charge_window_n] + if charge_limit_n == 0: + charge_window_n = -1 + else: + if self.set_charge_freeze and (charge_limit_n == self.reserve): + # Charge freeze via reserve + charge_limit_n = soc + + # When set reserve enable is on pretend the reserve is the charge limit minus the + # minimum battery rate modelled as it can leak a little + if self.set_reserve_enable: + reserve_expected = max(charge_limit_n - self.battery_rate_min * step, self.reserve) + + # Outside the recording window? + if minute >= end_record and record: + record = False + + # Store data before the next simulation step to align timestamps + stamp = minute_timestamp.strftime(TIME_FORMAT) + if self.debug_enable or save: + predict_soc_time[stamp] = round(soc, 3) + metric_time[stamp] = round(metric, 3) + load_kwh_time[stamp] = round(load_kwh, 3) + pv_kwh_time[stamp] = round(pv_kwh, 2) + import_kwh_time[stamp] = round(import_kwh, 2) + export_kwh_time[stamp] = round(export_kwh, 2) + for car_n in range(self.num_cars): + predict_car_soc_time[car_n][stamp] = round(car_soc[car_n] / self.car_charging_battery_size[car_n] * 100.0, 2) + predict_iboost[stamp] = iboost_today_kwh + record_time[stamp] = 0 if record else self.soc_max + + # Save Soc prediction data as minutes for later use + self.predict_soc[minute] = round(soc, 3) + if save and save == "best": + self.predict_soc_best[minute] = round(soc, 3) + self.predict_metric_best[minute] = round(metric, 3) + self.predict_iboost_best[minute] = iboost_today_kwh + self.predict_carbon_best[minute] = carbon_g + + # Add in standing charge, only for the final plan when we save the results + if (minute_absolute % (24 * 60)) < step and (save in ["best", "base", "base10", "best10"]): + metric += self.metric_standing_charge + + # Get load and pv forecast, total up for all values in the step + pv_now = 0 + load_yesterday = 0 + for offset in range(0, step, PREDICT_STEP): + pv_now += pv_forecast_minute_step.get(minute + offset, 0) + load_yesterday += load_minutes_step.get(minute + offset, 0) + + # Count PV kWh + pv_kwh += pv_now + if record: + final_pv_kwh = pv_kwh + + # Simulate car charging + car_load = self.in_car_slot(minute_absolute) + + # Car charging? + car_freeze = False + for car_n in range(self.num_cars): + if car_load[car_n] > 0.0: + car_load_scale = car_load[car_n] * step / 60.0 + car_load_scale = car_load_scale * self.car_charging_loss + car_load_scale = max(min(car_load_scale, self.car_charging_limit[car_n] - car_soc[car_n]), 0) + car_soc[car_n] = round(car_soc[car_n] + car_load_scale, 3) + load_yesterday += car_load_scale / self.car_charging_loss + # Model not allowing the car to charge from the battery + if not self.car_charging_from_battery: + discharge_rate_now = self.battery_rate_min # 0 + car_freeze = True + + # Reset modelled discharge rate if no car is charging + if not self.car_charging_from_battery and not car_freeze: + discharge_rate_now = self.battery_rate_max_discharge + + # IBoost solar diverter on load, don't do on discharge + iboost_amount = 0 + if self.iboost_enable and (discharge_window_n < 0): + if iboost_today_kwh < self.iboost_max_energy: + if self.iboost_gas: + if rate_gas: + # iBoost on cheap electric rates + gas_rate = rate_gas.get(minute_absolute, 99) * self.iboost_gas_scale + electric_rate = rate_import.get(minute_absolute, 0) + if (electric_rate < gas_rate) and (charge_window_n >= 0 or not self.iboost_charging): + iboost_amount = self.iboost_max_power * step + load_yesterday += iboost_amount + elif self.iboost_charging: + if charge_window_n >= 0: + iboost_amount = self.iboost_max_power * step + load_yesterday += iboost_amount + + # Count load + load_kwh += load_yesterday + if record: + final_load_kwh = load_kwh + + # Work out how much PV is used to satisfy home demand + pv_ac = min(load_yesterday / self.inverter_loss, pv_now, self.inverter_limit * step) + + # And hence how much maybe left for DC charging + pv_dc = pv_now - pv_ac + + # Scale down PV AC and DC for inverter loss (if hybrid we will reverse the DC loss later) + pv_ac *= self.inverter_loss + pv_dc *= self.inverter_loss + + # iBoost solar diverter model + if self.iboost_enable: + if iboost_today_kwh < self.iboost_max_energy and ( + self.iboost_solar and pv_dc > (self.iboost_min_power * step) and ((soc * 100.0 / self.soc_max) >= self.iboost_min_soc) + ): + iboost_amount = min(pv_dc, self.iboost_max_power * step) + pv_dc -= iboost_amount + + # Cumulative energy + iboost_today_kwh += iboost_amount + + # Model iboost reset + if (minute_absolute % (24 * 60)) == ((24 * 60) - step): + iboost_today_kwh = 0 + + # Save iBoost next prediction + if minute == 0 and save == "best": + scaled_boost = (iboost_amount / step) * RUN_EVERY + self.iboost_next = round(self.iboost_today + scaled_boost, 3) + if iboost_amount > 0: + self.iboost_running = True + else: + self.iboost_running = False + + # discharge freeze, reset charge rate by default + if self.set_discharge_freeze: + charge_rate_now = self.battery_rate_max_charge + # Freeze mode + if (discharge_window_n >= 0) and discharge_limits[discharge_window_n] < 100.0: + if self.set_discharge_freeze_only or ((soc - step * self.battery_rate_max_discharge_scaled) < (self.soc_max * discharge_limits[discharge_window_n] / 100.0)): + charge_rate_now = self.battery_rate_min # 0 + + # Set discharge during charge? + if not self.set_discharge_during_charge: + if (charge_window_n >= 0) and ((soc >= charge_limit_n) or not self.set_reserve_enable): + discharge_rate_now = self.battery_rate_min # 0 + elif not car_freeze: + # Reset discharge rate + discharge_rate_now = self.battery_rate_max_discharge + + # Battery behaviour + battery_draw = 0 + charge_rate_now_curve = get_charge_rate_curve(self, soc, charge_rate_now) + discharge_rate_now_curve = get_discharge_rate_curve(self, soc, discharge_rate_now) + battery_to_min = max(soc - self.reserve, 0) * self.battery_loss_discharge * self.inverter_loss + battery_to_max = max(self.soc_max - soc, 0) * self.battery_loss * self.inverter_loss + discharge_min = self.reserve + use_keep = self.best_soc_keep if four_hour_rule else self.reserve + if discharge_window_n >= 0: + discharge_min = max(self.soc_max * discharge_limits[discharge_window_n] / 100.0, self.reserve, use_keep, self.best_soc_min) + + if ( + not self.set_discharge_freeze_only + and (discharge_window_n >= 0) + and discharge_limits[discharge_window_n] < 100.0 + and (soc - step * self.battery_rate_max_discharge_scaled) > discharge_min + ): + # Discharge enable + discharge_rate_now = self.battery_rate_max_discharge_scaled # Assume discharge becomes enabled here + discharge_rate_now_curve = get_discharge_rate_curve(self, soc, discharge_rate_now) + + # It's assumed if SOC hits the expected reserve then it's terminated + reserve_expected = max((self.soc_max * discharge_limits[discharge_window_n]) / 100.0, self.reserve) + battery_draw = min(discharge_rate_now_curve * step, battery_to_min) + + # Account for export limit, clip battery draw if possible to avoid going over + diff_tmp = load_yesterday - (battery_draw + pv_dc + pv_ac) + if diff_tmp < 0 and abs(diff_tmp) > (self.export_limit * step): + above_limit = abs(diff_tmp + self.export_limit * step) + battery_draw = max(-charge_rate_now_curve * step, battery_draw - above_limit) + + # Account for inverter limit, clip battery draw if possible to avoid going over + diff_tmp = load_yesterday - (battery_draw + pv_dc + pv_ac) + if self.inverter_hybrid and diff_tmp < 0 and abs(diff_tmp) > (self.inverter_limit * step): + above_limit = abs(diff_tmp + self.inverter_limit * step) + battery_draw = max(-charge_rate_now_curve * step, battery_draw - above_limit) + + # If the battery is charging then solar will be used to charge as a priority + # So move more of the PV into PV DC + if battery_draw < 0 and pv_dc < abs(battery_draw): + extra_pv = min(abs(battery_draw) - pv_dc, pv_ac) + # Clamp to remaining energy to charge limit + if (extra_pv + pv_dc) > (charge_limit_n - soc): + extra_pv = max((charge_limit_n - soc) - pv_dc, 0) + pv_ac -= extra_pv + pv_dc += extra_pv + + if battery_draw < 0: + battery_state = "f/" + else: + battery_state = "f-" + + # Once force discharge starts the four hour rule is disabled + four_hour_rule = False + elif (charge_window_n >= 0) and soc < charge_limit_n: + # Charge enable + if save in ["best", "best10"]: + # Only tune charge rate on final plan not every simulation + charge_rate_now = ( + find_charge_rate(self, minute_absolute, soc, charge_window[charge_window_n], charge_limit_n, self.battery_rate_max_charge) * self.battery_rate_max_scaling + ) + else: + charge_rate_now = self.battery_rate_max_charge # Assume charge becomes enabled here + + # Apply the charging curve + charge_rate_now_curve = get_charge_rate_curve(self, soc, charge_rate_now) + + # If the battery is charging then solar will be used to charge as a priority + # So move more of the PV into PV DC + if pv_dc < charge_rate_now_curve * step: + extra_pv = min(charge_rate_now_curve * step - pv_dc, pv_ac) + # Clamp to remaining energy to charge limit + if (extra_pv + pv_dc) > (charge_limit_n - soc): + extra_pv = max((charge_limit_n - soc) - pv_dc, 0) + pv_ac -= extra_pv + pv_dc += extra_pv + + # Remove inverter loss as it will be added back in again when calculating the SOC change + charge_rate_now_curve /= self.inverter_loss + battery_draw = -max(min(charge_rate_now_curve * step, charge_limit_n - soc), 0, -battery_to_max) + battery_state = "f+" + first_charge = min(first_charge, minute) + else: + # ECO Mode + if load_yesterday - pv_ac - pv_dc > 0: + battery_draw = min(load_yesterday - pv_ac - pv_dc, discharge_rate_now_curve * step, self.inverter_limit * step - pv_ac, battery_to_min) + battery_state = "e-" + else: + battery_draw = max(load_yesterday - pv_ac - pv_dc, -charge_rate_now_curve * step, -battery_to_max) + if battery_draw < 0: + battery_state = "e+" + else: + battery_state = "e~" + + # Account for inverter limit, clip battery draw if possible to avoid going over + if self.inverter_hybrid: + total_inverted = pv_ac + max(pv_dc + battery_draw, 0) + else: + total_inverted = pv_ac + pv_dc + abs(battery_draw) + + if total_inverted > self.inverter_limit * step: + reduce_by = total_inverted - (self.inverter_limit * step) + if battery_draw < 0: + pv_ac -= reduce_by + if not self.inverter_hybrid and pv_ac < 0: + pv_dc = max(pv_dc + pv_ac, 0) + pv_ac = 0 + else: + battery_draw = max(0, battery_draw - reduce_by) + + # Clamp battery at reserve for discharge + if battery_draw > 0: + # All battery discharge must go through the inverter too + soc -= battery_draw / (self.battery_loss_discharge * self.inverter_loss) + if soc < reserve_expected: + battery_draw -= (reserve_expected - soc) * self.battery_loss_discharge * self.inverter_loss + soc = reserve_expected + + # Clamp battery at max when charging + if battery_draw < 0: + battery_draw_dc = max(-pv_dc, battery_draw) + battery_draw_ac = battery_draw - battery_draw_dc + + if self.inverter_hybrid: + inverter_loss = self.inverter_loss + else: + inverter_loss = 1.0 + + # In the hybrid case only we remove the inverter loss for PV charging (as it's DC to DC), and inverter loss was already applied + soc -= battery_draw_dc * self.battery_loss / inverter_loss + if soc > self.soc_max: + battery_draw_dc += ((soc - self.soc_max) / self.battery_loss) * inverter_loss + soc = self.soc_max + + # The rest of this charging must be from the grid (pv_dc was the left over PV) + soc -= battery_draw_ac * self.battery_loss * self.inverter_loss + if soc > self.soc_max: + battery_draw_ac += (soc - self.soc_max) / (self.battery_loss * self.inverter_loss) + soc = self.soc_max + + battery_draw = battery_draw_ac + battery_draw_dc + + # Rounding on SOC + soc = round(soc, 6) + + # Count battery cycles + battery_cycle = round(battery_cycle + abs(battery_draw), 4) + + # Work out left over energy after battery adjustment + diff = round(load_yesterday - (battery_draw + pv_dc + pv_ac), 6) + + if diff < 0: + # Can not export over inverter limit, load must be taken out first from the inverter limit + # All exports must come from PV or from the battery, so inverter loss is already accounted for in both cases + inverter_left = self.inverter_limit * step - load_yesterday + if inverter_left < 0: + diff += -inverter_left + else: + diff = max(diff, -inverter_left) + if diff < 0: + # Can not export over export limit, so cap at that + diff = max(diff, -self.export_limit * step) + + # Metric keep - pretend the battery is empty and you have to import instead of using the battery + if (soc < self.best_soc_keep) and (soc > self.reserve): + # Apply keep as a percentage of the time in the future so it gets stronger over an 4 hour period + # Weight to 50% chance of the scenario + if battery_draw > 0: + metric_keep += rate_import[minute_absolute] * battery_draw * keep_minute_scaling + elif soc < self.best_soc_keep: + # It seems odd but the reason to add in metric keep when the battery is empty because otherwise you weight an empty battery quite heavily + # and end up forcing it all to zero + keep_diff = load_yesterday - (0 + pv_dc + pv_ac) + if keep_diff > 0: + metric_keep += rate_import[minute_absolute] * keep_diff * keep_minute_scaling + + if diff > 0: + # Import + # All imports must go to home (no inverter loss) or to the battery (inverter loss accounted before above) + import_kwh += diff + + if self.carbon_enable: + carbon_g += diff * self.carbon_intensity.get(minute, 0) + + if charge_window_n >= 0: + # If the battery is on charge anyhow then imports are at battery charging rate + import_kwh_battery += diff + else: + # self.log("importing to minute %s amount %s kW total %s kWh total draw %s" % (minute, energy, import_kwh_house, diff)) + import_kwh_house += diff + + metric += rate_import[minute_absolute] * diff + grid_state = "<" + else: + # Export + energy = -diff + export_kwh += energy + if self.carbon_enable: + carbon_g -= energy * self.carbon_intensity.get(minute, 0) + + if minute_absolute in rate_export: + metric -= rate_export[minute_absolute] * energy + if diff != 0: + grid_state = ">" + else: + grid_state = "~" + + # Rounding for next stage + metric = round(metric, 4) + import_kwh_battery = round(import_kwh_battery, 6) + import_kwh_house = round(import_kwh_house, 6) + export_kwh = round(export_kwh, 6) + + # Store the number of minutes until the battery runs out + if record and soc <= self.reserve: + minute_left = min(minute, minute_left) + + # Record final soc & metric + if record: + final_soc = soc + for car_n in range(self.num_cars): + final_car_soc[car_n] = round(car_soc[car_n], 3) + if minute == 0: + # Next car SOC + self.car_charging_soc_next[car_n] = round(car_soc[car_n], 3) + + final_metric = metric + final_import_kwh = import_kwh + final_import_kwh_battery = import_kwh_battery + final_import_kwh_house = import_kwh_house + final_export_kwh = export_kwh + final_iboost_kwh = iboost_today_kwh + final_battery_cycle = battery_cycle + final_metric_keep = metric_keep + final_carbon_g = carbon_g + + # Store export data + if diff < 0: + predict_export[minute] = energy + if minute <= first_charge: + export_to_first_charge += energy + else: + predict_export[minute] = 0 + + # Soc at next charge start + if minute <= first_charge: + first_charge_soc = prev_soc + + # Have we past the charging or discharging time? + if charge_window_n >= 0: + charge_has_started = True + if charge_has_started and (charge_window_n < 0): + charge_has_run = True + if (discharge_window_n >= 0) and discharge_limits[discharge_window_n] < 100.0: + discharge_has_run = True + + # Record soc min + if record and (discharge_has_run or charge_has_run or not charge_window): + if soc < soc_min: + soc_min_minute = minute_absolute + soc_min = min(soc_min, soc) + + # Record state + if self.debug_enable or save: + predict_state[stamp] = "g" + grid_state + "b" + battery_state + predict_battery_power[stamp] = round(battery_draw * (60 / step), 3) + predict_battery_cycle[stamp] = round(battery_cycle, 3) + predict_pv_power[stamp] = round((pv_forecast_minute_step[minute] + pv_forecast_minute_step.get(minute + step, 0)) * (30 / step), 3) + predict_grid_power[stamp] = round(diff * (60 / step), 3) + predict_load_power[stamp] = round(load_yesterday * (60 / step), 3) + if self.carbon_enable: + predict_carbon_g[stamp] = round(carbon_g, 3) + + # if save == "best" and self.debug_enable: + # self.log("Best plan, minute {} soc {} charge_limit_n {} battery_cycle {} metric {} metric_keep {} soc_min {} diff {} import_battery {} import_house {} export {}".format(minute, soc, charge_limit_n, battery_cycle, metric, metric_keep, soc_min, diff, import_kwh_battery, import_kwh_house, export_kwh)) + minute += step + + hours_left = minute_left / 60.0 + + self.hours_left = hours_left + self.final_car_soc = final_car_soc + self.predict_car_soc_time = predict_car_soc_time + self.final_soc = final_soc + self.final_metric = final_metric + self.final_metric_keep = final_metric_keep + self.final_import_kwh = final_import_kwh + self.final_import_kwh_battery = final_import_kwh_battery + self.final_import_kwh_house = final_import_kwh_house + self.final_export_kwh = final_export_kwh + self.final_load_kwh = final_load_kwh + self.final_pv_kwh = final_pv_kwh + self.final_iboost_kwh = final_iboost_kwh + self.final_battery_cycle = final_battery_cycle + self.final_soc_min = soc_min + self.final_soc_min_minute = soc_min_minute + self.export_to_first_charge = export_to_first_charge + self.predict_soc_time = predict_soc_time + self.first_charge = first_charge + self.first_charge_soc = first_charge_soc + self.predict_state = predict_state + self.predict_battery_power = predict_battery_power + self.predict_battery_power = predict_battery_power + self.predict_pv_power = predict_pv_power + self.predict_grid_power = predict_grid_power + self.predict_load_power = predict_load_power + self.predict_iboost = predict_iboost + self.predict_carbon_g = predict_carbon_g + self.predict_export = predict_export + self.metric_time = metric_time + self.record_time = record_time + self.predict_battery_cycle = predict_battery_cycle + self.predict_battery_power = predict_battery_power + self.pv_kwh_h0 = pv_kwh_h0 + self.import_kwh_h0 = import_kwh_h0 + self.export_kwh_h0 = export_kwh_h0 + self.load_kwh_h0 = load_kwh_h0 + self.load_kwh_time = load_kwh_time + self.pv_kwh_time = pv_kwh_time + self.import_kwh_time = import_kwh_time + self.export_kwh_time = export_kwh_time + + return ( + round(final_metric, 4), + round(import_kwh_battery, 4), + round(import_kwh_house, 4), + round(export_kwh, 4), + soc_min, + round(final_soc, 4), + soc_min_minute, + round(final_battery_cycle, 4), + round(final_metric_keep, 4), + round(final_iboost_kwh, 4), + round(final_carbon_g, 4), + ) + + +class Inverter: + def self_test(self, minutes_now): + self.base.log(f"======= INVERTER CONTROL SELF TEST START - REST={self.rest_api} ========") + self.adjust_battery_target(99, False) + self.adjust_battery_target(100, False) + self.adjust_charge_rate(215) + self.adjust_charge_rate(self.battery_rate_max_charge) + self.adjust_discharge_rate(220) + self.adjust_discharge_rate(self.battery_rate_max_discharge) + self.adjust_reserve(100) + self.adjust_reserve(6) + self.adjust_reserve(4) + self.adjust_pause_mode(pause_charge=True) + self.adjust_pause_mode(pause_discharge=True) + self.adjust_pause_mode(pause_charge=True, pause_discharge=True) + self.adjust_pause_mode() + self.disable_charge_window() + timea = datetime.strptime("23:00:00", "%H:%M:%S") + timeb = datetime.strptime("23:01:00", "%H:%M:%S") + timec = datetime.strptime("05:00:00", "%H:%M:%S") + timed = datetime.strptime("05:01:00", "%H:%M:%S") + self.adjust_charge_window(timeb, timed, minutes_now) + self.adjust_charge_window(timea, timec, minutes_now) + self.adjust_force_discharge(False, timec, timed) + self.adjust_force_discharge(True, timea, timeb) + self.adjust_force_discharge(False) + self.base.log("======= INVERTER CONTROL SELF TEST END ========") + + if self.rest_api: + self.rest_api = None + self.rest_data = None + self.self_test(minutes_now) + exit + + def auto_restart(self, reason): + """ + Attempt to restart the services required + """ + if self.base.restart_active: + self.base.log("Warn: Inverter control auto restart already active, waiting...") + return + + # Trigger restart + self.base.log("Warn: Inverter control auto restart trigger: {}".format(reason)) + restart_command = self.base.get_arg("auto_restart", []) + if restart_command: + self.base.restart_active = True + if isinstance(restart_command, dict): + restart_command = [restart_command] + for command in restart_command: + shell = command.get("shell", None) + service = command.get("service", None) + addon = command.get("addon", None) + if addon: + addon = self.base.resolve_arg(service, addon, indirect=False) + entity_id = command.get("entity_id", None) + if entity_id: + entity_id = self.base.resolve_arg(service, entity_id, indirect=False) + if shell: + self.log("Calling restart shell command: {}".format(shell)) + os.system(shell) + if service: + if addon: + self.log("Calling restart service {} with addon {}".format(service, addon)) + self.base.call_service_wrapper(service, addon=addon) + elif entity_id: + self.log("Calling restart service {} with entity_id {}".format(service, entity_id)) + self.base.call_service_wrapper(service, entity_id=entity_id) + else: + self.log("Calling restart service {}".format(service)) + self.base.call_service_wrapper(service) + self.base.call_notify("Auto-restart service {} called due to: {}".format(service, reason)) + time.sleep(15) + raise Exception("Auto-restart triggered") + else: + self.log("Info: auto_restart not defined in apps.yaml, Predbat can't auto-restart inverter control") + + def __init__(self, base, id=0, quiet=False): + self.id = id + self.base = base + self.log = self.base.log + self.charge_enable_time = False + self.charge_start_time_minutes = self.base.forecast_minutes + self.charge_start_end_minutes = self.base.forecast_minutes + self.charge_window = [] + self.discharge_window = [] + self.discharge_limits = [] + self.current_charge_limit = 0.0 + self.soc_kw = 0 + self.soc_percent = 0 + self.rest_data = None + self.inverter_limit = 7500.0 + self.export_limit = 99999.0 + self.inverter_time = None + self.reserve_percent = self.base.get_arg("battery_min_soc", default=4.0, index=self.id) + self.reserve_percent_current = self.base.get_arg("battery_min_soc", default=4.0, index=self.id) + self.battery_scaling = self.base.get_arg("battery_scaling", default=1.0, index=self.id) + + self.reserve_max = 100 + self.battery_rate_max_raw = 0 + self.battery_rate_max_charge = 0 + self.battery_rate_max_discharge = 0 + self.battery_rate_max_charge_scaled = 0 + self.battery_rate_max_discharge_scaled = 0 + self.battery_power = 0 + self.pv_power = 0 + self.load_power = 0 + self.rest_api = None + self.in_calibration = False + + self.inverter_type = self.base.get_arg("inverter_type", "GE", indirect=False, index=self.id) + + # Read user defined inverter type + if "inverter" in self.base.args: + if self.inverter_type not in INVERTER_DEF: + INVERTER_DEF[self.inverter_type] = INVERTER_DEF["GE"].copy() + + inverter_def = self.base.args["inverter"] + if isinstance(inverter_def, list): + inverter_def = inverter_def[self.id] + + if isinstance(inverter_def, dict): + for key in inverter_def: + INVERTER_DEF[self.inverter_type][key] = inverter_def[key] + else: + self.log("Warn: Inverter {}: inverter definition is not a dictionary".format(self.id)) + + if self.inverter_type in INVERTER_DEF: + self.log(f"Inverter {self.id}: Type {self.inverter_type} {INVERTER_DEF[self.inverter_type]['name']}") + else: + raise ValueError("Inverter type {} not defined".format(self.inverter_type)) + + if self.inverter_type != "GE": + self.log("Warn: Inverter {}: Using inverter type {} - not all features are available".format(self.id, self.inverter_type)) + + # Load inverter brand definitions + self.reserve_max = self.base.get_arg("inverter_reserve_max", 100) + self.inv_has_rest_api = INVERTER_DEF[self.inverter_type]["has_rest_api"] + self.inv_has_mqtt_api = INVERTER_DEF[self.inverter_type]["has_mqtt_api"] + self.inv_has_service_api = INVERTER_DEF[self.inverter_type]["has_service_api"] + self.inv_mqtt_topic = self.base.get_arg("mqtt_topic", "Sofar2mqtt") + self.inv_output_charge_control = INVERTER_DEF[self.inverter_type]["output_charge_control"] + self.inv_current_dp = INVERTER_DEF[self.inverter_type].get("current_dp", 1) + self.inv_has_charge_enable_time = INVERTER_DEF[self.inverter_type]["has_charge_enable_time"] + self.inv_has_discharge_enable_time = INVERTER_DEF[self.inverter_type]["has_discharge_enable_time"] + self.inv_has_target_soc = INVERTER_DEF[self.inverter_type]["has_target_soc"] + self.inv_has_reserve_soc = INVERTER_DEF[self.inverter_type]["has_reserve_soc"] + self.inv_has_timed_pause = INVERTER_DEF[self.inverter_type]["has_timed_pause"] + self.inv_charge_time_format = INVERTER_DEF[self.inverter_type]["charge_time_format"] + self.inv_charge_time_entity_is_option = INVERTER_DEF[self.inverter_type]["charge_time_entity_is_option"] + self.inv_clock_time_format = INVERTER_DEF[self.inverter_type]["clock_time_format"] + self.inv_soc_units = INVERTER_DEF[self.inverter_type]["soc_units"] + self.inv_time_button_press = INVERTER_DEF[self.inverter_type]["time_button_press"] + self.inv_support_charge_freeze = INVERTER_DEF[self.inverter_type]["support_charge_freeze"] + self.inv_support_discharge_freeze = INVERTER_DEF[self.inverter_type]["support_discharge_freeze"] + self.inv_has_ge_inverter_mode = INVERTER_DEF[self.inverter_type]["has_ge_inverter_mode"] + self.inv_num_load_entities = INVERTER_DEF[self.inverter_type]["num_load_entities"] + self.inv_write_and_poll_sleep = INVERTER_DEF[self.inverter_type]["write_and_poll_sleep"] + self.inv_has_idle_time = INVERTER_DEF[self.inverter_type]["has_idle_time"] + self.inv_can_span_midnight = INVERTER_DEF[self.inverter_type]["can_span_midnight"] + + # If it's not a GE inverter then turn Quiet off + if self.inverter_type != "GE": + quiet = False + + # Rest API for GivEnergy + if self.inverter_type == "GE": + self.rest_api = self.base.get_arg("givtcp_rest", None, indirect=False, index=self.id) + if self.rest_api: + if not quiet: + self.base.log("Inverter {} using Rest API {}".format(self.id, self.rest_api)) + self.rest_data = self.rest_readData() + if not self.rest_data: + self.auto_restart("REST read failure") + + # Timed pause support? + if self.inv_has_timed_pause: + entity_mode = self.base.get_arg("pause_mode", indirect=False, index=self.id) + if entity_mode: + old_pause_mode = self.base.get_state_wrapper(entity_mode) + if old_pause_mode is None: + self.inv_has_timed_pause = False + self.log("Inverter {} does not have timed pause support enabled".format(self.id)) + else: + self.log("Inverter {} has timed pause support enabled".format(self.id)) + else: + self.inv_has_timed_pause = False + self.log("Inverter {} does not have timed pause support enabled".format(self.id)) + + # Battery size, charge and discharge rates + ivtime = None + if self.rest_data and ("Invertor_Details" in self.rest_data): + idetails = self.rest_data["Invertor_Details"] + self.soc_max = float(idetails["Battery_Capacity_kWh"]) + self.nominal_capacity = self.soc_max + if "raw" in self.rest_data: + raw_data = self.rest_data["raw"] + invname = "invertor" + if invname not in raw_data: + invname = "inverter" + if invname in raw_data and "battery_nominal_capacity" in raw_data[invname]: + self.nominal_capacity = ( + float(raw_data[invname]["battery_nominal_capacity"]) / 19.53125 + ) # XXX: Where does 19.53125 come from? I back calculated but why that number... + if self.base.battery_capacity_nominal: + if abs(self.soc_max - self.nominal_capacity) > 1.0: + # XXX: Weird workaround for battery reporting wrong capacity issue + self.base.log("Warn: REST data reports Battery Capacity kWh as {} but nominal indicates {} - using nominal".format(self.soc_max, self.nominal_capacity)) + self.soc_max = self.nominal_capacity + if invname in raw_data and "soc_force_adjust" in raw_data[invname]: + soc_force_adjust = raw_data[invname]["soc_force_adjust"] + if soc_force_adjust: + try: + soc_force_adjust = int(soc_force_adjust) + except ValueError: + soc_force_adjust = 0 + if (soc_force_adjust > 0) and (soc_force_adjust < 7): + self.in_calibration = True + self.log("Warn: Inverter is in calibration mode {}, Predbat will not function correctly and will be disabled".format(soc_force_adjust)) + self.soc_max *= self.battery_scaling + + # Max battery rate + if "Invertor_Max_Bat_Rate" in idetails: + self.battery_rate_max_raw = idetails["Invertor_Max_Bat_Rate"] + elif "Invertor_Max_Rate" in idetails: + self.battery_rate_max_raw = idetails["Invertor_Max_Rate"] + else: + self.battery_rate_max_raw = self.base.get_arg("charge_rate", attribute="max", index=self.id, default=2600.0) + + # Max invertor rate + if "Invertor_Max_Inv_Rate" in idetails: + self.inverter_limit = idetails["Invertor_Max_Inv_Rate"] + + # Inverter time + if "Invertor_Time" in idetails: + ivtime = idetails["Invertor_Time"] + else: + self.soc_max = self.base.get_arg("soc_max", default=10.0, index=self.id) * self.battery_scaling + self.nominal_capacity = self.soc_max + + self.battery_voltage = 52.0 + if "battery_voltage" in self.base.args: + self.base.get_arg("battery_voltage", index=self.id, default=52.0) + + if self.inverter_type in ["GE", "GEC", "GEE"]: + self.battery_rate_max_raw = self.base.get_arg("charge_rate", attribute="max", index=self.id, default=2600.0) + elif "battery_rate_max" in self.base.args: + self.battery_rate_max_raw = self.base.get_arg("battery_rate_max", index=self.id, default=2600.0) + else: + self.battery_rate_max_raw = 2600.0 + + ivtime = self.base.get_arg("inverter_time", index=self.id, default=None) + + # Battery can not be zero size + if self.soc_max <= 0: + self.base.log("Error: Reported battery size from REST is {}, but it must be >0".format(self.soc_max)) + raise ValueError + + # Battery rate max charge, discharge (all converted to kW/min) + self.battery_rate_max_charge = min(self.base.get_arg("inverter_limit_charge", self.battery_rate_max_raw, index=self.id), self.battery_rate_max_raw) / MINUTE_WATT + self.battery_rate_max_discharge = min(self.base.get_arg("inverter_limit_discharge", self.battery_rate_max_raw, index=self.id), self.battery_rate_max_raw) / MINUTE_WATT + self.battery_rate_max_charge_scaled = self.battery_rate_max_charge * self.base.battery_rate_max_scaling + self.battery_rate_max_discharge_scaled = self.battery_rate_max_discharge * self.base.battery_rate_max_scaling_discharge + self.battery_rate_min = min(self.base.get_arg("inverter_battery_rate_min", 0, index=self.id), self.battery_rate_max_raw) / MINUTE_WATT + + # Convert inverter time into timestamp + if ivtime: + try: + self.inverter_time = datetime.strptime(ivtime, TIME_FORMAT) + except (ValueError, TypeError): + try: + self.inverter_time = datetime.strptime(ivtime, TIME_FORMAT_OCTOPUS) + except (ValueError, TypeError): + try: + tz = pytz.timezone(self.base.get_arg("timezone", "Europe/London")) + self.inverter_time = tz.localize(datetime.strptime(ivtime, self.inv_clock_time_format)) + except (ValueError, TypeError): + self.base.log(f"Warn: Unable to read inverter time string {ivtime} using formats {[TIME_FORMAT, TIME_FORMAT_OCTOPUS, self.inv_clock_time_format]}") + self.inverter_time = None + self.auto_restart("Unable to read inverter time") + + # Check inverter time and confirm skew + if self.inverter_time: + # Fetch current time again as it may have changed since we run this inverter update + local_tz = pytz.timezone(self.base.get_arg("timezone", "Europe/London")) + now_utc = datetime.now(local_tz) + + tdiff = self.inverter_time - now_utc + tdiff = self.base.dp2(tdiff.seconds / 60 + tdiff.days * 60 * 24) + if not quiet: + self.base.log("Invertor time {} AppDaemon time {} difference {} minutes".format(self.inverter_time, now_utc, tdiff)) + if abs(tdiff) >= 10: + self.base.log( + "Warn: Invertor time is {} AppDaemon time {} this is {} minutes skewed, Predbat may not function correctly, please fix this by updating your inverter or fixing AppDaemon time zone".format( + self.inverter_time, now_utc, tdiff + ) + ) + self.base.record_status( + "Invertor time is {} AppDaemon time {} this is {} minutes skewed, Predbat may not function correctly, please fix this by updating your inverter or fixing AppDaemon time zone".format( + self.inverter_time, now_utc, tdiff + ), + had_errors=True, + ) + # Trigger restart + self.auto_restart("Clock skew >=10 minutes") + else: + self.base.restart_active = False + + # Get current reserve value + if self.rest_data: + self.reserve_percent_current = float(self.rest_data["Control"]["Battery_Power_Reserve"]) + else: + self.reserve_percent_current = max(self.base.get_arg("reserve", default=0.0, index=self.id), self.base.get_arg("battery_min_soc", default=4.0, index=self.id)) + self.reserve_current = self.base.dp2(self.soc_max * self.reserve_percent_current / 100.0) + + # Get the expected minimum reserve value + battery_min_soc = self.base.get_arg("battery_min_soc", default=4.0, index=self.id) + self.reserve_min = self.base.get_arg("set_reserve_min") + if self.reserve_min < battery_min_soc: + self.base.log(f"Increasing set_reserve_min from {self.reserve_min}% to battery_min_soc of {battery_min_soc}%") + self.base.expose_config("set_reserve_min", battery_min_soc) + self.reserve_min = battery_min_soc + + self.base.log(f"Reserve min: {self.reserve_min}% Battery_min:{battery_min_soc}%") + if self.base.set_reserve_enable: + self.reserve_percent = self.reserve_min + else: + self.reserve_percent = self.reserve_percent_current + self.reserve = self.base.dp2(self.soc_max * self.reserve_percent / 100.0) + + # Max inverter rate override + if "inverter_limit" in self.base.args: + self.inverter_limit = self.base.get_arg("inverter_limit", self.inverter_limit, index=self.id) / MINUTE_WATT + if "export_limit" in self.base.args: + self.export_limit = self.base.get_arg("export_limit", self.inverter_limit, index=self.id) / MINUTE_WATT + # Can't export more than the inverter limit + self.export_limit = min(self.export_limit, self.inverter_limit) + + # Log inverter details + if not quiet: + self.base.log( + "Inverter {} with soc_max {} kWh nominal_capacity {} kWh battery rate raw {} w charge rate {} kW discharge rate {} kW battery_rate_min {} w ac limit {} kW export limit {} kW reserve {} % current_reserve {} %".format( + self.id, + self.base.dp2(self.soc_max), + self.base.dp2(self.nominal_capacity), + self.base.dp2(self.battery_rate_max_raw), + self.base.dp2(self.battery_rate_max_charge * 60.0), + self.base.dp2(self.battery_rate_max_discharge * 60.0), + self.base.dp2(self.battery_rate_min * MINUTE_WATT), + self.base.dp2(self.inverter_limit * 60), + self.base.dp2(self.export_limit * 60), + self.reserve_percent, + self.reserve_percent_current, + ) + ) + + # Create some dummy entities if PredBat expects them but they don't exist for this Inverter Type: + # Args are also set for these so that no entries are needed for the dummies in the config file + if not self.inv_has_charge_enable_time: + if ("scheduled_charge_enable" not in self.base.args) or (not isinstance(self.base.args["scheduled_charge_enable"], list)): + self.base.args["scheduled_charge_enable"] = ["on", "on", "on", "on"] + self.base.args["scheduled_charge_enable"][id] = self.create_entity("scheduled_charge_enable", "on") + + if not self.inv_has_discharge_enable_time: + if ("scheduled_discharge_enable" not in self.base.args) or (not isinstance(self.base.args["scheduled_discharge_enable"], list)): + self.base.args["scheduled_discharge_enable"] = ["on", "on", "on", "on"] + self.base.args["scheduled_discharge_enable"][id] = self.create_entity("scheduled_discharge_enable", "on") + + if not self.inv_has_reserve_soc: + self.base.args["reserve"] = self.create_entity("reserve", self.reserve, device_class="battery", uom="%") + + if not self.inv_has_target_soc: + self.base.args["charge_limit"] = self.create_entity("charge_limit", 100, device_class="battery", uom="%") + + if self.inv_output_charge_control != "power": + self.base.args["charge_rate"] = self.create_entity("charge_rate", int(self.battery_rate_max_charge * MINUTE_WATT), uom="W", device_class="power") + self.base.args["discharge_rate"] = self.create_entity("discharge_rate", int(self.battery_rate_max_discharge * MINUTE_WATT), uom="W", device_class="power") + + if not self.inv_has_ge_inverter_mode: + if "inverter_mode" not in self.base.args: + self.base.args["inverter_mode"] = ["Eco", "Eco", "Eco", "Eco"] + self.base.args["inverter_mode"][id] = self.create_entity("inverter_mode", "Eco") + + if self.inv_charge_time_format != "HH:MM:SS": + for x in ["charge", "discharge"]: + for y in ["start", "end"]: + self.base.args[f"{x}_{y}_time"] = self.create_entity(f"{x}_{y}_time", "23:59:00") + + def find_charge_curve(self, discharge): + """ + Find expected charge curve + """ + curve_type = "charge" + if discharge: + curve_type = "discharge" + + soc_kwh_sensor = self.base.get_arg("soc_kw", indirect=False, index=self.id) + if discharge: + charge_rate_sensor = self.base.get_arg("discharge_rate", indirect=False, index=self.id) + else: + charge_rate_sensor = self.base.get_arg("charge_rate", indirect=False, index=self.id) + predbat_status_sensor = "predbat.status" + battery_power_sensor = self.base.get_arg("battery_power", indirect=False, index=self.id) + final_curve = {} + final_curve_count = {} + + if discharge: + max_power = int(self.battery_rate_max_discharge * MINUTE_WATT) + else: + max_power = int(self.battery_rate_max_charge * MINUTE_WATT) + + if soc_kwh_sensor and charge_rate_sensor and battery_power_sensor and predbat_status_sensor: + battery_power_sensor = battery_power_sensor.replace("number.", "sensor.") # Workaround as old template had number. + self.log("Find {} curve with sensors {} and {} and {} and {}".format(curve_type, soc_kwh_sensor, charge_rate_sensor, predbat_status_sensor, battery_power_sensor)) + soc_kwh_data = self.base.get_history_wrapper(entity_id=soc_kwh_sensor, days=self.base.max_days_previous) + charge_rate_data = self.base.get_history_wrapper(entity_id=charge_rate_sensor, days=self.base.max_days_previous) + predbat_status_data = self.base.get_history_wrapper(entity_id=predbat_status_sensor, days=self.base.max_days_previous) + battery_power_data = self.base.get_history_wrapper(entity_id=battery_power_sensor, days=self.base.max_days_previous) + + if soc_kwh_data and charge_rate_data and charge_rate_data and battery_power_data: + soc_kwh = self.base.minute_data( + soc_kwh_data[0], + self.base.max_days_previous, + self.base.now_utc, + "state", + "last_updated", + backwards=True, + clean_increment=False, + smoothing=False, + divide_by=1.0, + scale=self.battery_scaling, + required_unit="kWh", + ) + charge_rate = self.base.minute_data( + charge_rate_data[0], + self.base.max_days_previous, + self.base.now_utc, + "state", + "last_updated", + backwards=True, + clean_increment=False, + smoothing=False, + divide_by=1.0, + scale=1.0, + required_unit="W", + ) + predbat_status = self.base.minute_data_state(predbat_status_data[0], self.base.max_days_previous, self.base.now_utc, "state", "last_updated") + battery_power = self.base.minute_data( + battery_power_data[0], + self.base.max_days_previous, + self.base.now_utc, + "state", + "last_updated", + backwards=True, + clean_increment=False, + smoothing=False, + divide_by=1.0, + scale=1.0, + required_unit="W", + ) + min_len = min(len(soc_kwh), len(charge_rate), len(predbat_status), len(battery_power)) + self.log("Find {} curve has {} days of data, max days {}".format(curve_type, min_len / 60 / 24.0, self.base.max_days_previous)) + + soc_percent = {} + for minute in range(0, min_len): + soc_percent[minute] = calc_percent_limit(soc_kwh.get(minute, 0), self.soc_max) + + if discharge: + search_range = range(5, 20, 1) + else: + search_range = range(99, 85, -1) + + # Find 100% end points + for data_point in search_range: + for minute in range(1, min_len): + # Start trigger is when the SOC just increased above the data point + if ( + not discharge + and soc_percent.get(minute - 1, 0) == (data_point + 1) + and soc_percent.get(minute, 0) == data_point + and predbat_status.get(minute - 1, "") == "Charging" + and predbat_status.get(minute, "") == "Charging" + and predbat_status.get(minute + 1, "") == "Charging" + and charge_rate.get(minute - 1, 0) == max_power + and charge_rate.get(minute, 0) == max_power + and battery_power.get(minute, 0) < 0 + ) or ( + discharge + and soc_percent.get(minute - 1, 0) == (data_point - 1) + and soc_percent.get(minute, 0) == data_point + and predbat_status.get(minute - 1, "") == "Discharging" + and predbat_status.get(minute, "") == "Discharging" + and predbat_status.get(minute + 1, "") == "Discharging" + and charge_rate.get(minute - 1, 0) == max_power + and charge_rate.get(minute, 0) == max_power + and battery_power.get(minute, 0) > 0 + ): + total_power = 0 + total_count = 0 + # Find a period where charging was at full rate and the SOC just drops below the data point + for target_minute in range(minute, min_len): + this_soc = soc_percent.get(target_minute, 0) + if not discharge and ( + predbat_status.get(target_minute, "") != "Charging" or charge_rate.get(minute, 0) != max_power or battery_power.get(minute, 0) >= 0 + ): + break + if discharge and ( + predbat_status.get(target_minute, "") != "Discharging" or charge_rate.get(minute, 0) != max_power or battery_power.get(minute, 0) <= 0 + ): + break + + if (discharge and (this_soc > data_point)) or (not discharge and (this_soc < data_point)): + if total_power == 0: + # No power data, so skip this data point + break + if discharge: + this_soc -= 1 + else: + this_soc += 1 + # So the power for this data point average has been stored, it's possible we spanned more than one data point + # if not all SOC %'s are represented for this battery size + from_soc = soc_kwh.get(minute, 0) + to_soc = soc_kwh.get(target_minute, 0) + soc_charged = from_soc - to_soc + average_power = total_power / total_count + charge_curve = round(min(average_power / max_power / self.base.battery_loss, 1.0), 2) + if self.base.debug_enable: + self.log( + "Curve Percent: {}-{} at {} took {} minutes charged {} curve {} average_power {}".format( + data_point, + this_soc, + self.base.time_abs_str(self.base.minutes_now - minute), + total_count, + round(soc_charged, 2), + charge_curve, + average_power, + ) + ) + # Store data points + if discharge: + store_range = range(this_soc, data_point - 1, -1) + else: + store_range = range(this_soc, data_point + 1) + + for point in store_range: + if point not in final_curve: + final_curve[point] = charge_curve + final_curve_count[point] = 1 + else: + final_curve[point] += charge_curve + final_curve_count[point] += 1 + + break + else: + # Store data + total_power += abs(battery_power.get(minute, 0)) + total_count += 1 + if final_curve: + # Average the data points + for index in final_curve: + if final_curve_count[index] > 0: + final_curve[index] = self.base.dp2(final_curve[index] / final_curve_count[index]) + + self.log("Curve before adjustment is: {}".format(final_curve)) + + # Find info for gap filling + found_required = False + if discharge: + fill_range = range(4, 21, 1) + value = min(final_curve.values()) + else: + value = max(final_curve.values()) + fill_range = range(85, 101) + + # Pick the first value point for fill below + for point in fill_range: + if point in final_curve: + value = final_curve[point] + break + + # Fill gaps + for point in fill_range: + if point not in final_curve: + final_curve[point] = value + found_required = True + else: + value = final_curve[point] + + if found_required: + # Scale curve to 1.0 + rate_scaling = 0 + + # Work out the maximum power to use as a scale factor + for index in final_curve: + rate_scaling = max(final_curve[index], rate_scaling) + + # Scale the curve + if rate_scaling > 0: + for index in final_curve: + final_curve[index] = round(final_curve[index] / rate_scaling, 2) + + # If we have the correct data then output it + if rate_scaling > 0: + if discharge: + text = " battery_discharge_power_curve:\n" + else: + text = " battery_charge_power_curve:\n" + keys = sorted(final_curve.keys()) + keys.reverse() + first = True + for key in keys: + if (final_curve[key] < 1.0 or first) and final_curve[key] > 0.0: + text += " {} : {}\n".format(key, final_curve[key]) + first = False + if self.base.battery_charge_power_curve_auto: + self.log("Curve automatically computed as:\n" + text) + else: + self.log("Curve curve can be entered into apps.yaml or set to auto:\n" + text) + rate_scaling = round(rate_scaling, 2) + if discharge: + if rate_scaling != self.base.battery_rate_max_scaling_discharge: + self.log( + "Consider setting in HA: input_number.battery_rate_max_scaling_discharge: {} - currently {}".format( + rate_scaling, self.base.battery_rate_max_scaling_discharge + ) + ) + else: + if rate_scaling != self.base.battery_rate_max_scaling: + self.log( + "Consider setting in HA: input_number.battery_rate_max_scaling: {} - currently {}".format(rate_scaling, self.base.battery_rate_max_scaling) + ) + return final_curve + else: + self.log("Note: Found incorrect battery charging curve (was 0), maybe try again when you have more data.") + else: + self.log("Note: Found incomplete battery charging curve (no data points), maybe try again when you have more data.") + else: + self.log( + "Note: Can not find battery charge curve (no final curve), one of the required settings for predbat_status, soc_kw, battery_power and charge_rate do not have history, check apps.yaml" + ) + else: + self.log( + "Note: Can not find battery charge curve (missing history), one of the required settings for predbat_status, soc_kw, battery_power and charge_rate do not have history, check apps.yaml" + ) + else: + self.log( + "Note: Can not find battery charge curve (settings missing), one of the required settings for soc_kw, battery_power and charge_rate are missing from apps.yaml" + ) + return {} + + def create_entity(self, entity_name, value, uom=None, device_class="None"): + """ + Create dummy entities required by non GE inverters to mimic GE behaviour + """ + if "prefix" in self.base.args: + prefix = self.base.get_arg("prefix", indirect=False) + else: + prefix = "prefix" + + entity_id = f"sensor.{prefix}_{self.inverter_type}_{self.id}_{entity_name}" + + if self.base.get_state_wrapper(entity_id) is None: + attributes = { + "state_class": "measurement", + } + + if uom is not None: + attributes["unit_of_measurement"] = uom + if device_class is not None: + attributes["device_class"] = device_class + + self.base.set_state_wrapper(entity_id, state=value, attributes=attributes) + return entity_id + + def update_status(self, minutes_now, quiet=False): + """ + Update the following with inverter status. + + Inverter Class Parameters + ========================= + + Parameter Type Units + --------- ---- ----- + self.rest_data dict + self.charge_enable_time bool + self.discharge_enable_time bool + self.charge_rate_now float + self.discharge_rate_now float + self.soc_kw float kWh + self.soc_percent float % + self.battery_power float W + self.pv_power float W + self.load_power float W + self.charge_start_time_minutes int + self.charge_end_time_minutes int + self.charge_window list of dicts + self.discharge_start_time_minutes int + self.discharge_end_time_minutes int + self.discharge_window list of dicts + self.battery_voltage float V + + Output Entities: + ================ + + Config arg Type Units + ---------- ---- ----- + None + """ + + # If it's not a GE inverter then turn Quiet off + if self.inverter_type != "GE": + quiet = False + + self.battery_power = 0 + self.pv_power = 0 + self.load_power = 0 + + if self.rest_api: + self.rest_data = self.rest_readData() + + if self.rest_data: + self.charge_enable_time = self.rest_data["Control"]["Enable_Charge_Schedule"] == "enable" + self.discharge_enable_time = self.rest_data["Control"]["Enable_Discharge_Schedule"] == "enable" + self.charge_rate_now = self.rest_data["Control"]["Battery_Charge_Rate"] / MINUTE_WATT + self.discharge_rate_now = self.rest_data["Control"]["Battery_Discharge_Rate"] / MINUTE_WATT + else: + self.log( + "Inverter {} scheduled_charge_enable {} scheduled_discharge_enable {}".format( + self.id, self.base.get_arg("scheduled_charge_enable", "on", index=self.id), self.base.get_arg("scheduled_discharge_enable", "off", index=self.id) + ) + ) + self.charge_enable_time = self.base.get_arg("scheduled_charge_enable", "on", index=self.id) == "on" + self.discharge_enable_time = self.base.get_arg("scheduled_discharge_enable", "off", index=self.id) == "on" + self.charge_rate_now = self.base.get_arg("charge_rate", index=self.id, default=2600.0) / MINUTE_WATT + self.discharge_rate_now = self.base.get_arg("discharge_rate", index=self.id, default=2600.0) / MINUTE_WATT + + # Scale charge and discharge rates with battery scaling + self.charge_rate_now = max(self.charge_rate_now * self.base.battery_rate_max_scaling, self.battery_rate_min) + self.discharge_rate_now = max(self.discharge_rate_now * self.base.battery_rate_max_scaling_discharge, self.battery_rate_min) + + if SIMULATE: + self.soc_kw = self.base.sim_soc_kw + else: + if self.rest_data: + self.soc_kw = self.rest_data["Power"]["Power"]["SOC_kWh"] * self.battery_scaling + else: + if "soc_percent" in self.base.args: + self.soc_kw = self.base.get_arg("soc_percent", default=0.0, index=self.id) * self.soc_max * self.battery_scaling / 100.0 + else: + self.soc_kw = self.base.get_arg("soc_kw", default=0.0, index=self.id) * self.battery_scaling + + if self.soc_max <= 0.0: + self.soc_percent = 0 + else: + self.soc_percent = calc_percent_limit(self.soc_kw, self.soc_max) + + if self.rest_data and ("Power" in self.rest_data): + pdetails = self.rest_data["Power"] + if "Power" in pdetails: + ppdetails = pdetails["Power"] + self.battery_power = float(ppdetails.get("Battery_Power", 0.0)) + self.pv_power = float(ppdetails.get("PV_Power", 0.0)) + self.load_power = float(ppdetails.get("Load_Power", 0.0)) + else: + self.battery_power = self.base.get_arg("battery_power", default=0.0, index=self.id) + self.pv_power = self.base.get_arg("pv_power", default=0.0, index=self.id) + self.load_power = self.base.get_arg("load_power", default=0.0, index=self.id) + + for i in range(1, self.inv_num_load_entities): + self.load_power += self.base.get_arg(f"load_power_{i}", default=0.0, index=self.id) + + self.battery_voltage = self.base.get_arg("battery_voltage", default=52.0, index=self.id) + + if not quiet: + self.base.log( + "Inverter {} SOC: {} kW {} % Current charge rate {} w Current discharge rate {} w Current power {} w Current voltage {}".format( + self.id, + self.base.dp2(self.soc_kw), + self.soc_percent, + self.charge_rate_now * MINUTE_WATT, + self.discharge_rate_now * MINUTE_WATT, + self.battery_power, + self.battery_voltage, + ) + ) + + # If the battery is being charged then find the charge window + if self.charge_enable_time or not self.inv_has_charge_enable_time: + # Find current charge window + if SIMULATE: + charge_start_time = datetime.strptime(self.base.sim_charge_start_time, "%H:%M:%S") + charge_end_time = datetime.strptime(self.base.sim_charge_end_time, "%H:%M:%S") + else: + if self.rest_data: + charge_start_time = datetime.strptime(self.rest_data["Timeslots"]["Charge_start_time_slot_1"], "%H:%M:%S") + charge_end_time = datetime.strptime(self.rest_data["Timeslots"]["Charge_end_time_slot_1"], "%H:%M:%S") + elif "charge_start_time" in self.base.args: + charge_start_time = datetime.strptime(self.base.get_arg("charge_start_time", index=self.id), "%H:%M:%S") + charge_end_time = datetime.strptime(self.base.get_arg("charge_end_time", index=self.id), "%H:%M:%S") + else: + self.log("Error: Inverter {} unable to read charge window time as neither REST, charge_start_time or charge_start_hour are set".format(self.id)) + self.base.record_status( + "Error: Inverter {} unable to read charge window time as neither REST, charge_start_time or charge_start_hour are set".format(self.id), had_errors=True + ) + raise ValueError + + # Update simulated charge enable time to match the charge window time. + if not self.inv_has_charge_enable_time: + if charge_start_time == charge_end_time: + self.charge_enable_time = False + else: + self.charge_enable_time = True + self.write_and_poll_switch("scheduled_charge_enable", self.base.get_arg("scheduled_charge_enable", indirect=False, index=self.id), self.charge_enable_time) + self.log("Inverter {} scheduled_charge_enable set to {}".format(self.id, self.charge_enable_time)) + + # Track charge start/end + self.track_charge_start = charge_start_time.strftime("%H:%M:%S") + self.track_charge_end = charge_end_time.strftime("%H:%M:%S") + + # Reverse clock skew + charge_start_time -= timedelta(seconds=self.base.inverter_clock_skew_start * 60) + charge_end_time -= timedelta(seconds=self.base.inverter_clock_skew_end * 60) + + # Compute charge window minutes start/end just for the next charge window + self.charge_start_time_minutes = charge_start_time.hour * 60 + charge_start_time.minute + self.charge_end_time_minutes = charge_end_time.hour * 60 + charge_end_time.minute + + if self.charge_end_time_minutes < self.charge_start_time_minutes: + # As windows wrap, if end is in the future then move start back, otherwise forward + if self.charge_end_time_minutes > minutes_now: + self.charge_start_time_minutes -= 60 * 24 + else: + self.charge_end_time_minutes += 60 * 24 + + # Window already passed, move it forward until the next one + if self.charge_end_time_minutes < minutes_now: + self.charge_start_time_minutes += 60 * 24 + self.charge_end_time_minutes += 60 * 24 + + else: + # If charging is disabled set a fake window outside + self.charge_start_time_minutes = self.base.forecast_minutes + self.charge_end_time_minutes = self.base.forecast_minutes + self.track_charge_start = "00:00:00" + self.track_charge_end = "00:00:00" + + # Construct charge window from the GivTCP settings + self.charge_window = [] + + if not quiet: + self.base.log("Inverter {} scheduled charge enable is {}".format(self.id, self.charge_enable_time)) + + if self.charge_enable_time: + minute = max(0, self.charge_start_time_minutes) # Max is here is start could be before midnight now + minute_end = self.charge_end_time_minutes + while minute < (self.base.forecast_minutes + minutes_now): + window = {} + window["start"] = minute + window["end"] = minute_end + window["average"] = 0 # Rates are not known yet + self.charge_window.append(window) + minute += 24 * 60 + minute_end += 24 * 60 + + if not quiet: + self.base.log("Inverter {} charge windows currently {}".format(self.id, self.charge_window)) + + # Work out existing charge limits and percent + if self.charge_enable_time: + if self.rest_data: + self.current_charge_limit = float(self.rest_data["Control"]["Target_SOC"]) + else: + self.current_charge_limit = float(self.base.get_arg("charge_limit", index=self.id, default=100.0)) + else: + self.current_charge_limit = 0.0 + + if not quiet: + if self.charge_enable_time: + self.base.log( + "Inverter {} Charge settings: {}-{} limit {} power {} kW".format( + self.id, + self.base.time_abs_str(self.charge_start_time_minutes), + self.base.time_abs_str(self.charge_end_time_minutes), + self.current_charge_limit, + self.charge_rate_now * 60.0, + ) + ) + else: + self.base.log("Inverter {} Charge settings: timed charged is disabled, power {} kW".format(self.id, round(self.charge_rate_now * 60.0, 2))) + + # Construct discharge window from GivTCP settings + self.discharge_window = [] + + if self.rest_data: + discharge_start = datetime.strptime(self.rest_data["Timeslots"]["Discharge_start_time_slot_1"], "%H:%M:%S") + discharge_end = datetime.strptime(self.rest_data["Timeslots"]["Discharge_end_time_slot_1"], "%H:%M:%S") + elif "discharge_start_time" in self.base.args: + discharge_start = datetime.strptime(self.base.get_arg("discharge_start_time", index=self.id), "%H:%M:%S") + discharge_end = datetime.strptime(self.base.get_arg("discharge_end_time", index=self.id), "%H:%M:%S") + else: + self.log("Error: Inverter {} unable to read Discharge window as neither REST or discharge_start_time are set".format(self.id)) + self.base.record_status("Error: Inverter {} unable to read Discharge window as neither REST or discharge_start_time are set".format(self.id), had_errors=True) + raise ValueError + + # Update simulated discharge enable time to match the discharge window time. + if not self.inv_has_discharge_enable_time: + if discharge_start == discharge_end: + self.discharge_enable_time = False + else: + self.discharge_enable_time = True + entity_id = self.base.get_arg("scheduled_discharge_enable", indirect=False, index=self.id) + self.write_and_poll_switch("scheduled_discharge_enable", entity_id, self.discharge_enable_time) + self.log("Inverter {} {} set to {}".format(self.id, entity_id, self.discharge_enable_time)) + + # Tracking for idle time + if self.discharge_enable_time: + self.track_discharge_start = discharge_start.strftime("%H:%M:%S") + self.track_discharge_end = discharge_end.strftime("%H:%M:%S") + else: + self.track_discharge_start = "00:00:00" + self.track_discharge_end = "00:00:00" + + # Reverse clock skew + discharge_start -= timedelta(seconds=self.base.inverter_clock_skew_discharge_start * 60) + discharge_end -= timedelta(seconds=self.base.inverter_clock_skew_discharge_end * 60) + + # Compute discharge window minutes start/end just for the next discharge window + self.discharge_start_time_minutes = discharge_start.hour * 60 + discharge_start.minute + self.discharge_end_time_minutes = discharge_end.hour * 60 + discharge_end.minute + + if self.discharge_end_time_minutes < self.discharge_start_time_minutes: + # As windows wrap, if end is in the future then move start back, otherwise forward + if self.discharge_end_time_minutes > minutes_now: + self.discharge_start_time_minutes -= 60 * 24 + else: + self.discharge_end_time_minutes += 60 * 24 + + if not quiet: + self.base.log("Inverter {} scheduled discharge enable is {}".format(self.id, self.discharge_enable_time)) + # Pre-fill current discharge window + # Store it even when discharge timed isn't enabled as it won't be outside the actual slot + if True: + minute = max(0, self.discharge_start_time_minutes) # Max is here is start could be before midnight now + minute_end = self.discharge_end_time_minutes + while minute < self.base.forecast_minutes: + window = {} + window["start"] = minute + window["end"] = minute_end + window["average"] = 0 # Rates are not known yet + self.discharge_window.append(window) + minute += 24 * 60 + minute_end += 24 * 60 + + # Pre-fill best discharge enables + if self.discharge_enable_time: + self.discharge_limits = [0.0 for i in range(len(self.discharge_window))] + else: + self.discharge_limits = [100.0 for i in range(len(self.discharge_window))] + + if not quiet: + self.base.log("Inverter {} discharge windows currently {}".format(self.id, self.discharge_window)) + + if INVERTER_TEST: + self.self_test(minutes_now) + + def mimic_target_soc(self, current_charge_limit): + """ + Function to turn on/off charging based on the current SOC and the set charge limit + + Parameters: + current_charge_limit (float): The target SOC (State of Charge) limit for charging. + + Returns: + None + """ + charge_power = self.base.get_arg("charge_rate", index=self.id, default=2600.0) + if current_charge_limit == 0: + self.alt_charge_discharge_enable("eco", True) # ECO Mode + elif self.soc_percent > float(current_charge_limit): + # If current SOC is above Target SOC, turn Grid Charging off + self.alt_charge_discharge_enable("charge", False) + self.base.log(f"Current SOC {self.soc_percent}% is greater than Target SOC {current_charge_limit}. Grid Charge disabled.") + elif self.soc_percent == float(current_charge_limit): # If SOC target is reached + self.alt_charge_discharge_enable("charge", True) # Make sure charging is on + if self.inv_output_charge_control == "current": + self.set_current_from_power("charge", (0)) # Set charge current to zero (i.e hold SOC) + self.base.log(f"Current SOC {self.soc_percent}% is same as Target SOC {current_charge_limit}. Grid Charge enabled, Amps rate set to 0.") + else: + # If we drop below the target, turn grid charging back on and make sure the charge current is correct + self.alt_charge_discharge_enable("charge", True) + if self.inv_output_charge_control == "current": + self.set_current_from_power("charge", charge_power) # Write previous current setting to inverter + self.base.log(f"Current SOC {self.soc_percent}% is less than Target SOC {current_charge_limit}. Grid Charge enabled, amp rate written to inverter.") + self.base.log( + f"Current SOC {self.soc_percent}% is less than Target SOC {current_charge_limit}. Grid charging enabled with charge current set to {self.base.get_arg('timed_charge_current', index=self.id, default=65):0.2f}" + ) + + def adjust_reserve(self, reserve): + """ + Adjust the output reserve target % + + Inverter Class Parameters + ========================= + + None + + Output Entities: + ================ + + Config arg Type Units + ---------- ---- ----- + reserve int % + + """ + + if SIMULATE: + current_reserve = float(self.base.sim_reserve) + else: + if self.rest_data: + current_reserve = float(self.rest_data["Control"]["Battery_Power_Reserve"]) + else: + current_reserve = self.base.get_arg("reserve", index=self.id, default=0.0) + + # Round to integer and clamp to minimum + reserve = int(reserve + 0.5) + if reserve < self.reserve_percent: + reserve = self.reserve_percent + + # Clamp reserve at max setting + reserve = min(reserve, self.reserve_max) + + if current_reserve != reserve: + self.base.log("Inverter {} Current Reserve is {} % and new target is {} %".format(self.id, current_reserve, reserve)) + if SIMULATE: + self.base.sim_reserve = reserve + else: + if self.rest_data: + self.rest_setReserve(reserve) + else: + self.write_and_poll_value("reserve", self.base.get_arg("reserve", indirect=False, index=self.id), reserve) + if self.base.set_inverter_notify: + self.base.call_notify("Predbat: Inverter {} Target Reserve has been changed to {} at {}".format(self.id, reserve, self.base.time_now_str())) + self.mqtt_message(topic="set/reserve", payload=reserve) + else: + self.base.log("Inverter {} Current reserve is {} already at target".format(self.id, current_reserve)) + + def adjust_charge_rate(self, new_rate, notify=True): + """ + Adjust charging rate + + Inverter Class Parameters + ========================= + + None + + Output Entities: + ================ + + Config arg Type Units + ---------- ---- ----- + charge_rate float W + *timed_charge_current float A + + + *If the inverter uses current rather than power we create a dummy entity for the power anyway but also write to the current entity + """ + + new_rate = int(new_rate + 0.5) + + if SIMULATE: + current_rate = self.base.sim_charge_rate_now + else: + if self.rest_data: + current_rate = int(self.rest_data["Control"]["Battery_Charge_Rate"]) + else: + current_rate = self.base.get_arg("charge_rate", index=self.id, default=2600.0) + + if abs(current_rate - new_rate) > 100: + self.base.log("Inverter {} current charge rate is {} and new target is {}".format(self.id, current_rate, new_rate)) + if SIMULATE: + self.base.sim_charge_rate_now = new_rate + else: + if self.rest_data: + self.rest_setChargeRate(new_rate) + else: + if "charge_rate" in self.base.args: + self.write_and_poll_value( + "charge_rate", self.base.get_arg("charge_rate", indirect=False, index=self.id), new_rate, fuzzy=(self.battery_rate_max_charge * MINUTE_WATT / 12) + ) + + if self.inv_output_charge_control == "current": + self.set_current_from_power("charge", new_rate) + + if notify and self.base.set_inverter_notify: + self.base.call_notify("Predbat: Inverter {} charge rate changes to {} at {}".format(self.id, new_rate, self.base.time_now_str())) + self.mqtt_message(topic="set/charge_rate", payload=new_rate) + + def adjust_discharge_rate(self, new_rate, notify=True): + """ + Adjust discharging rate + + Inverter Class Parameters + ========================= + + None + + Output Entities: + ================ + + Config arg Type Units + ---------- ---- ----- + discharge_rate float W + *timed_discharge_current float A + + *If the inverter uses current rather than power we create a dummy entity for the power anyway but also write to the current entity + """ + new_rate = int(new_rate + 0.5) + + if SIMULATE: + current_rate = self.base.sim_discharge_rate_now + else: + if self.rest_data: + current_rate = self.rest_data["Control"]["Battery_Discharge_Rate"] + else: + current_rate = self.base.get_arg("discharge_rate", index=self.id, default=2600.0) + + if abs(current_rate - new_rate) > 100: + self.base.log("Inverter {} current discharge rate is {} and new target is {}".format(self.id, current_rate, new_rate)) + if SIMULATE: + self.base.sim_discharge_rate_now = new_rate + else: + if self.rest_data: + self.rest_setDischargeRate(new_rate) + else: + if "discharge_rate" in self.base.args: + self.write_and_poll_value( + "discharge_rate", + self.base.get_arg("discharge_rate", indirect=False, index=self.id), + new_rate, + fuzzy=(self.battery_rate_max_discharge * MINUTE_WATT / 25), + ) + + if self.inv_output_charge_control == "current": + self.set_current_from_power("discharge", new_rate) + + if notify and self.base.set_inverter_notify: + self.base.call_notify("Predbat: Inverter {} discharge rate changes to {} at {}".format(self.id, new_rate, self.base.time_now_str())) + self.mqtt_message(topic="set/discharge_rate", payload=new_rate) + + def adjust_battery_target(self, soc, isCharging=False): + """ + Adjust the battery charging target SOC % in GivTCP + + Inverter Class Parameters + ========================= + + None + + Output Entities: + ================ + + Config arg Type Units + ---------- ---- ----- + charge_limit int % + + """ + # SOC has no decimal places and clamp in min + soc = int(max(soc, self.reserve_percent)) + + # Check current setting and adjust + if SIMULATE: + current_soc = self.base.sim_soc + else: + if self.rest_data: + current_soc = int(float(self.rest_data["Control"]["Target_SOC"])) + else: + current_soc = int(float(self.base.get_arg("charge_limit", index=self.id, default=100.0))) + + if current_soc != soc: + self.base.log("Inverter {} Current charge limit is {} % and new target is {} %".format(self.id, current_soc, soc)) + self.current_charge_limit = soc + if SIMULATE: + self.base.sim_soc = soc + else: + if self.rest_data: + self.rest_setChargeTarget(soc) + else: + self.write_and_poll_value("charge_limit", self.base.get_arg("charge_limit", indirect=False, index=self.id), soc) + + if self.base.set_inverter_notify: + self.base.call_notify("Predbat: Inverter {} Target SOC has been changed to {} % at {}".format(self.id, soc, self.base.time_now_str())) + self.mqtt_message(topic="set/target_soc", payload=soc) + else: + self.base.log("Inverter {} Current Target SOC is {} already at target".format(self.id, current_soc)) + + # Inverters that need on/off controls rather than target SOC + if not self.inv_has_target_soc: + if isCharging: + self.mimic_target_soc(soc) + else: + self.mimic_target_soc(0) + + def write_and_poll_switch(self, name, entity_id, new_value): + """ + GivTCP Workaround, keep writing until correct + """ + # Re-written to minimise writes + domain, entity_name = entity_id.split(".") + + current_state = self.base.get_state_wrapper(entity_id=entity_id) + if isinstance(current_state, str): + current_state = current_state.lower() in ["on", "enable", "true"] + + retry = 0 + while current_state != new_value and retry < 6: + retry += 1 + if domain == "sensor": + if new_value: + self.base.set_state_wrapper(state="on", entity_id=entity_id) + else: + self.base.set_state_wrapper(state="off", entity_id=entity_id) + else: + base_entity = entity_id.split(".")[0] + service = base_entity + "/turn_" + ("on" if new_value else "off") + self.base.call_service_wrapper(service, entity_id=entity_id) + + time.sleep(self.inv_write_and_poll_sleep) + current_state = self.base.get_state_wrapper(entity_id=entity_id, refresh=True) + self.log("Switch {} is now {}".format(entity_id, current_state)) + if isinstance(current_state, str): + current_state = current_state.lower() in ["on", "enable", "true"] + + if current_state == new_value: + self.base.log("Inverter {} Wrote {} to {} successfully and got {}".format(self.id, name, new_value, self.base.get_state_wrapper(entity_id=entity_id))) + return True + else: + self.base.log("Warn: Inverter {} Trying to write {} to {} didn't complete got {}".format(self.id, name, new_value, self.base.get_state_wrapper(entity_id=entity_id))) + self.base.record_status("Warn: Inverter {} write to {} failed".format(self.id, name), had_errors=True) + return False + + def write_and_poll_value(self, name, entity_id, new_value, fuzzy=0): + # Modified to cope with sensor entities and writing strings + # Re-written to minimise writes + domain, entity_name = entity_id.split(".") + current_state = self.base.get_state_wrapper(entity_id) + + if isinstance(new_value, str): + matched = current_state == new_value + else: + matched = abs(float(current_state) - new_value) <= fuzzy + + retry = 0 + while (not matched) and (retry < 6): + retry += 1 + if domain == "sensor": + self.base.set_state_wrapper(entity_id, state=new_value) + else: + entity_base = entity_id.split(".")[0] + service = entity_base + "/set_value" + + self.base.call_service_wrapper(service, value=new_value, entity_id=entity_id) + + time.sleep(self.inv_write_and_poll_sleep) + current_state = self.base.get_state_wrapper(entity_id, refresh=True) + if isinstance(new_value, str): + matched = current_state == new_value + else: + matched = abs(float(current_state) - new_value) <= fuzzy + + if retry == 0: + self.base.log(f"Inverter {self.id} No write needed for {name}: {new_value} == {current_state}") + return True + elif matched: + self.base.log(f"Inverter {self.id} Wrote {new_value} to {name}, successfully now {current_state}") + return True + else: + self.base.log(f"Warn: Inverter {self.id} Trying to write {new_value} to {name} didn't complete got {current_state}") + self.base.record_status(f"Warn: Inverter {self.id} write to {name} failed", had_errors=True) + return False + + def write_and_poll_option(self, name, entity_id, new_value): + """ + GivTCP Workaround, keep writing until correct + """ + for retry in range(6): + entity_base = entity_id.split(".")[0] + service = entity_base + "/select_option" + self.base.call_service_wrapper(service, option=new_value, entity_id=entity_id) + time.sleep(self.inv_write_and_poll_sleep) + old_value = self.base.get_state_wrapper(entity_id, refresh=True) + if old_value == new_value: + self.base.log("Inverter {} Wrote {} to {} successfully".format(self.id, name, new_value)) + return True + self.base.log("Warn: Inverter {} Trying to write {} to {} didn't complete got {}".format(self.id, name, new_value, self.base.get_state_wrapper(entity_id, refresh=True))) + self.base.record_status("Warn: Inverter {} write to {} failed".format(self.id, name), had_errors=True) + return False + + def adjust_pause_mode(self, pause_charge=False, pause_discharge=False): + """ + Inverter control for Pause mode + """ + + # Ignore if inverter doesn't have pause mode + if not self.inv_has_timed_pause: + return + + entity_mode = self.base.get_arg("pause_mode", indirect=False, index=self.id) + entity_start = self.base.get_arg("pause_start_time", indirect=False, index=self.id) + entity_end = self.base.get_arg("pause_end_time", indirect=False, index=self.id) + old_pause_mode = None + old_start_time = None + old_end_time = None + + # As not all inverters have these options we need to gracefully give up if its missing + if entity_mode: + old_pause_mode = self.base.get_state_wrapper(entity_mode) + if old_pause_mode is None: + entity_mode = None + + if entity_start: + old_start_time = self.base.get_state_wrapper(entity_start) + if old_start_time is None: + entity_start = None + self.log("Note: Inverter {} does not have pause_start_time entity".format(self.id)) + + if entity_end: + old_end_time = self.base.get_state_wrapper(entity_end) + if old_end_time is None: + self.log("Note: Inverter {} does not have pause_end_time entity".format(self.id)) + entity_end = None + + if not entity_mode: + self.log("Warn: Inverter {} does not have pause_mode entity configured correctly".format(self.id)) + return + + # Some inverters have start/end time registers + new_start_time = "00:00:00" + new_end_time = "23:59:00" + + if pause_charge and pause_discharge: + new_pause_mode = "PauseBoth" + elif pause_charge: + new_pause_mode = "PauseCharge" + elif pause_discharge: + new_pause_mode = "PauseDischarge" + else: + new_pause_mode = "Disabled" + + if old_start_time and old_start_time != new_start_time: + # Don't poll as inverters with no registers will fail + self.base.set_state_wrapper(entity_start, state=new_start_time) + self.base.log("Inverter {} set pause start time to {}".format(self.id, new_start_time)) + if old_end_time and old_end_time != new_end_time: + # Don't poll as inverters with no registers will fail + self.base.set_state_wrapper(entity_end, state=new_end_time) + self.base.log("Inverter {} set pause end time to {}".format(self.id, new_end_time)) + + # Set the mode + if new_pause_mode != old_pause_mode: + self.write_and_poll_option("pause_mode", entity_mode, new_pause_mode) + + if self.base.set_inverter_notify: + self.base.call_notify("Predbat: Inverter {} pause mode to set {} at time {}".format(self.id, new_pause_mode, self.base.time_now_str())) + + self.base.log("Inverter {} set pause mode to {}".format(self.id, new_pause_mode)) + + def adjust_inverter_mode(self, force_discharge, changed_start_end=False): + """ + Adjust inverter mode between force discharge and ECO + + Inverter Class Parameters + ========================= + + None + + Output Entities: + ================ + + Config arg Type Units + ---------- ---- ----- + inverter_mode string + + """ + if SIMULATE: + old_inverter_mode = self.base.sim_inverter_mode + else: + if self.rest_data: + old_inverter_mode = self.rest_data["Control"]["Mode"] + else: + # Inverter mode + if changed_start_end and not self.rest_data: + # XXX: Workaround for GivTCP window state update time to take effort + self.base.log("Sleeping (workaround) as start/end of discharge window was just adjusted") + time.sleep(30) + old_inverter_mode = self.base.get_arg("inverter_mode", index=self.id) + + # For the purpose of this function consider Eco Paused as the same as Eco (it's a difference in reserve setting) + if old_inverter_mode == "Eco (Paused)": + old_inverter_mode = "Eco" + + # Force discharge or Eco mode? + if force_discharge: + new_inverter_mode = "Timed Export" + else: + new_inverter_mode = "Eco" + + # Change inverter mode + if old_inverter_mode != new_inverter_mode: + if SIMULATE: + self.base.sim_inverter_mode = new_inverter_mode + else: + if self.rest_data: + self.rest_setBatteryMode(new_inverter_mode) + else: + entity_id = self.base.get_arg("inverter_mode", indirect=False, index=self.id) + if self.inv_has_ge_inverter_mode: + self.write_and_poll_option("inverter_mode", entity_id, new_inverter_mode) + else: + self.write_and_poll_value("inverter_mode", entity_id, new_inverter_mode) + + # Notify + if self.base.set_inverter_notify: + self.base.call_notify("Predbat: Inverter {} Force discharge set to {} at time {}".format(self.id, force_discharge, self.base.time_now_str())) + + self.base.log("Inverter {} set force discharge to {}".format(self.id, force_discharge)) + + def adjust_idle_time(self, charge_start=None, charge_end=None, discharge_start=None, discharge_end=None): + """ + Adjust inverter idle time based on charge/discharge times + """ + if charge_start: + self.track_charge_start = charge_start + if charge_end: + self.track_charge_end = charge_end + if discharge_start: + self.track_discharge_start = discharge_start + if discharge_end: + self.track_discharge_end = discharge_end + + self.log("Adjust idle time, charge {}-{} discharge {}-{}".format(self.track_charge_start, self.track_charge_end, self.track_discharge_start, self.track_discharge_end)) + + minutes_now = self.base.minutes_now + charge_start_minutes, charge_end_minutes = self.window2minutes(self.track_charge_start, self.track_charge_end, "%H:%M:%S", minutes_now) + discharge_start_minutes, discharge_end_minutes = self.window2minutes(self.track_discharge_start, self.track_discharge_end, "%H:%M:%S", minutes_now) + + # Idle from now until midnight + idle_start_minutes = minutes_now + idle_end_minutes = 2 * 24 * 60 - 1 + + if charge_start_minutes <= minutes_now and charge_end_minutes > minutes_now: + # We are in a charge window so move on the idle start + idle_start_minutes = max(idle_start_minutes, charge_end_minutes) + # self.log("Clamp idle start until after charge start - idle start now {}".format(idle_start_minutes)) + + if idle_end_minutes > charge_start_minutes and idle_start_minutes < charge_start_minutes: + # Avoid the end running over the charge start + idle_end_minutes = min(idle_end_minutes, charge_start_minutes) + # self.log("Clamp idle end until before start charge - idle end now {}".format(idle_end_minutes)) + + if discharge_start_minutes <= minutes_now and discharge_end_minutes > minutes_now: + # We are in a discharge window so move on the idle start + idle_start_minutes = max(idle_start_minutes, discharge_end_minutes) + # self.log("Clamp idle start until after discharge start - idle start now {}".format(idle_start_minutes)) + + if idle_end_minutes > discharge_start_minutes and idle_start_minutes < discharge_start_minutes: + # Avoid the end running over the discharge start + idle_end_minutes = min(idle_end_minutes, discharge_start_minutes) + # self.log("Clamp idle end until before discharge charge - idle end now {}".format(idle_end_minutes)) + + # Avoid midnight span + if idle_start_minutes < 24 * 60: + idle_end_minutes = min(24 * 60 - 1, idle_end_minutes) + # self.log("clamp idle end at midnight, now {}".format(idle_end_minutes)) + + if idle_start_minutes >= idle_end_minutes: + # Not until tomorrow so skip for now + idle_start_minutes = 0 + idle_end_minutes = 0 + # self.log("Reset idle start/end due to being no window") + + idle_start_time = self.base.midnight_utc + timedelta(minutes=idle_start_minutes) + idle_end_time = self.base.midnight_utc + timedelta(minutes=idle_end_minutes) + idle_start = idle_start_time.strftime("%H:%M:%S") + idle_end = idle_end_time.strftime("%H:%M:%S") + + self.base.log("Adjust idle time computed idle is {}-{}".format(idle_start, idle_end)) + + # Write idle start/end time + if self.inv_has_idle_time: + idle_start_time_id = self.base.get_arg("idle_start_time", indirect=False, index=self.id) + idle_end_time_id = self.base.get_arg("idle_end_time", indirect=False, index=self.id) + + if idle_start_time_id and idle_end_time_id: + old_start = self.base.get_arg("idle_start_time", index=self.id) + old_end = self.base.get_arg("idle_end_time", index=self.id) + + if old_start != idle_start: + self.base.log("Inverter {} set new idle start time to {} was {}".format(self.id, idle_start, old_start)) + self.write_and_poll_option("idle_start_time", idle_start_time_id, idle_start) + if old_end != idle_end: + self.base.log("Inverter {} set new idle end time to {} was {}".format(self.id, idle_end, old_end)) + self.write_and_poll_option("idle_end_time", idle_end_time_id, idle_end) + + def window2minutes(self, start, end, format, minutes_now): + """ + Convert time start/end window string into minutes + """ + start = datetime.strptime(start, format) + end = datetime.strptime(end, format) + start_minute = start.hour * 60 + start.minute + end_minute = end.hour * 60 + end.minute + + if end_minute < start_minute: + # As windows wrap, if end is in the future then move start back, otherwise forward + if end_minute > minutes_now: + start_minute -= 60 * 24 + else: + end_minute += 60 * 24 + + # Window already passed, move it forward until the next one + if end_minute < minutes_now: + start_minute += 60 * 24 + end_minute += 60 * 24 + return start_minute, end_minute + + def adjust_force_discharge(self, force_discharge, new_start_time=None, new_end_time=None): + """ + Adjust force discharge on/off and set the time window correctly + + Inverter Class Parameters + ========================= + + None + + Output Entities: + ================ + + Config arg Type Units + ---------- ---- ----- + discharge_start_time string + discharge_end_time string + *discharge_start_hour int + *discharge_start_minute int + *discharge_end_hour int + *discharge_end_minute int + *charge_discharge_update_button button + + """ + + if SIMULATE: + old_start = self.base.sim_discharge_start + old_end = self.base.sim_discharge_end + old_discharge_enable = False + else: + if self.rest_data: + old_start = self.rest_data["Timeslots"]["Discharge_start_time_slot_1"] + old_end = self.rest_data["Timeslots"]["Discharge_end_time_slot_1"] + old_discharge_enable = self.rest_data["Control"]["Enable_Discharge_Schedule"] + elif "discharge_start_time" in self.base.args: + old_start = self.base.get_arg("discharge_start_time", index=self.id) + old_end = self.base.get_arg("discharge_end_time", index=self.id) + old_discharge_enable = self.base.get_arg("scheduled_discharge_enable", "off", index=self.id) == "on" + else: + self.log("Warn: Inverter {} unable read discharge window as neither REST, discharge_start_time or discharge_start_hour are set".format(self.id)) + return False + + # If the inverter doesn't have a discharge enable time then use midnight-midnight as an alternative disable + if not self.inv_has_discharge_enable_time and not force_discharge: + new_start_time = self.base.midnight_utc + new_end_time = self.base.midnight_utc + + # Start time to correct format + if new_start_time: + new_start_time += timedelta(seconds=self.base.inverter_clock_skew_discharge_start * 60) + new_start = new_start_time.strftime("%H:%M:%S") + else: + new_start = None + + # End time to correct format + if new_end_time: + new_end_time += timedelta(seconds=self.base.inverter_clock_skew_discharge_end * 60) + new_end = new_end_time.strftime("%H:%M:%S") + else: + new_end = None + + # Eco mode, turn it on before we change the discharge window + if not force_discharge: + self.adjust_inverter_mode(force_discharge) + + self.base.log("Inverter {} Adjust force discharge to {}, change times from {} - {} to {} - {}".format(self.id, force_discharge, old_start, old_end, new_start, new_end)) + changed_start_end = False + + # Some inverters have an idle time setting + if force_discharge: + self.adjust_idle_time(discharge_start=new_start, discharge_end=new_end) + else: + self.adjust_idle_time(discharge_start="00:00:00", discharge_end="00:00:00") + + # Change start time + if new_start and new_start != old_start: + self.base.log("Inverter {} set new start time to {}".format(self.id, new_start)) + if SIMULATE: + self.base.sim_discharge_start = new_start + else: + if self.rest_data: + pass # REST writes as a single start/end time + + elif "discharge_start_time" in self.base.args: + # Always write to this as it is the GE default + changed_start_end = True + entity_discharge_start_time_id = self.base.get_arg("discharge_start_time", indirect=False, index=self.id) + if self.inv_charge_time_entity_is_option: + self.write_and_poll_option("discharge_start_time", entity_discharge_start_time_id, new_start) + else: + self.write_and_poll_value("discharge_start_time", entity_discharge_start_time_id, new_start) + + if self.inv_charge_time_format == "H M": + # If the inverter uses hours and minutes then write to these entities too + self.write_and_poll_value("discharge_start_hour", self.base.get_arg("discharge_start_hour", indirect=False, index=self.id), int(new_start[:2])) + self.write_and_poll_value("discharge_start_minute", self.base.get_arg("discharge_start_minute", indirect=False, index=self.id), int(new_start[3:5])) + else: + self.log("Warn: Inverter {} unable write discharge start time as neither REST or discharge_start_time are set".format(self.id)) + + # Change end time + if new_end and new_end != old_end: + self.base.log("Inverter {} Set new end time to {} was {}".format(self.id, new_end, old_end)) + if SIMULATE: + self.base.sim_discharge_end = new_end + else: + if self.rest_data: + pass # REST writes as a single start/end time + elif "discharge_end_time" in self.base.args: + # Always write to this as it is the GE default + changed_start_end = True + entity_discharge_end_time_id = self.base.get_arg("discharge_end_time", indirect=False, index=self.id) + if self.inv_charge_time_entity_is_option: + self.write_and_poll_option("discharge_end_time", entity_discharge_end_time_id, new_end) + # If the inverter uses hours and minutes then write to these entities too + else: + self.write_and_poll_value("discharge_end_time", entity_discharge_end_time_id, new_end) + + if self.inv_charge_time_format == "H M": + self.write_and_poll_value("discharge_end_hour", self.base.get_arg("discharge_end_hour", indirect=False, index=self.id), int(new_end[:2])) + self.write_and_poll_value("discharge_end_minute", self.base.get_arg("discharge_end_minute", indirect=False, index=self.id), int(new_end[3:5])) + else: + self.log("Warn: Inverter {} unable write discharge end time as neither REST or discharge_end_time are set".format(self.id)) + + if ((new_end != old_end) or (new_start != old_start)) and self.inv_time_button_press: + entity_id = self.base.get_arg("charge_discharge_update_button", indirect=False, index=self.id) + self.press_and_poll_button(entity_id) + + # Change scheduled discharge enable + if force_discharge and not old_discharge_enable: + if not SIMULATE: + self.write_and_poll_switch("scheduled_discharge_enable", self.base.get_arg("scheduled_discharge_enable", indirect=False, index=self.id), True) + self.log("Inverter {} Turning on scheduled discharge".format(self.id)) + elif not force_discharge and old_discharge_enable: + if not SIMULATE: + self.write_and_poll_switch("scheduled_discharge_enable", self.base.get_arg("scheduled_discharge_enable", indirect=False, index=self.id), False) + self.log("Inverter {} Turning off scheduled discharge".format(self.id)) + + # REST version of writing slot + if self.rest_data and new_start and new_end and ((new_start != old_start) or (new_end != old_end)): + changed_start_end = True + if not SIMULATE: + self.rest_setDischargeSlot1(new_start, new_end) -# Import AppDaemon or our standalone wrapper -try: - import adbase as ad - import appdaemon.plugins.hass.hassapi as hass -except: - import hass as hass + # Force discharge, turn it on after we change the window + if force_discharge: + self.adjust_inverter_mode(force_discharge, changed_start_end=changed_start_end) -import pytz -import requests -import yaml -from multiprocessing import Pool, cpu_count, set_start_method -import asyncio -import json + # Notify + if changed_start_end: + if self.base.set_inverter_notify: + self.base.call_notify("Predbat: Inverter {} Discharge time slot set to {} - {} at time {}".format(self.id, new_start, new_end, self.base.time_now_str())) -THIS_VERSION = "v8.0.0" -PREDBAT_FILES = ["predbat.py", "config.py", "prediction.py", "utils.py", "inverter.py", "ha.py"] - -from config import ( - TIME_FORMAT, - TIME_FORMAT_SOLCAST, - TIME_FORMAT_SECONDS, - TIME_FORMAT_OCTOPUS, - PREDICT_STEP, - MINUTE_WATT, - PREDBAT_MODE_OPTIONS, - PREDBAT_MODE_MONITOR, - CONFIG_ITEMS, - RUN_EVERY, - INVERTER_TEST, - SIMULATE, - MAX_INCREMENT, - CONFIG_ROOTS, - PREDBAT_MODE_CONTROL_SOC, - PREDBAT_MODE_CONTROL_CHARGE, - PREDBAT_MODE_CONTROL_CHARGEDISCHARGE, - SIMULATE_LENGTH, - CONFIG_REFRESH_PERIOD, - TIME_FORMAT_HA, - TIMEOUT, -) -from prediction import Prediction, wrapped_run_prediction_single, wrapped_run_prediction_charge, wrapped_run_prediction_discharge, reset_prediction_globals -from utils import remove_intersecting_windows, get_charge_rate_curve, get_discharge_rate_curve, find_charge_rate, calc_percent_limit -from inverter import Inverter -from ha import HAInterface + def disable_charge_window(self, notify=True): + """ + Disable charge window + """ + """ + Adjust force discharge on/off and set the time window correctly -""" -Used to mimic threads when they are disabled -""" + Inverter Class Parameters + ========================= + Parameter Type Units + ---------- ---- ----- + self.charge_enable_time boole + + + Output Entities: + ================ + + Config arg Type Units + ---------- ---- ----- + scheduled_charge_enable bool + *charge_start_hour int + *charge_start_minute int + *charge_end_hour int + *charge_end_minute int + *charge_discharge_update_button button -class DummyThread: - def __init__(self, result): """ - Store the data into the class + if SIMULATE: + old_charge_schedule_enable = self.base.sim_charge_schedule_enable + else: + if self.rest_data: + old_charge_schedule_enable = self.rest_data["Control"]["Enable_Charge_Schedule"] + else: + old_charge_schedule_enable = self.base.get_arg("scheduled_charge_enable", "on", index=self.id) + + self.adjust_idle_time(charge_start="00:00:00", charge_end="00:00:00") + + if old_charge_schedule_enable == "on" or old_charge_schedule_enable == "enable": + if not SIMULATE: + # Enable scheduled charge if not turned on + if self.rest_data: + self.rest_enableChargeSchedule(False) + else: + self.write_and_poll_switch("scheduled_charge_enable", self.base.get_arg("scheduled_charge_enable", indirect=False, index=self.id), False) + # If there's no charge enable switch then we can enable using start and end time + if not self.inv_has_charge_enable_time and (self.inv_output_charge_control == "current"): + self.enable_charge_discharge_with_time_current("charge", False) + else: + self.adjust_charge_window(self.base.midnight_utc, self.base.midnight_utc, self.base.minutes_now) + + if self.base.set_inverter_notify and notify: + self.base.call_notify("Predbat: Inverter {} Disabled scheduled charging at {}".format(self.id, self.base.time_now_str())) + else: + self.base.sim_charge_schedule_enable = "off" + + self.base.log("Inverter {} Turning off scheduled charge".format(self.id)) + + # Updated cached status to disabled + self.charge_enable_time = False + self.charge_start_time_minutes = self.base.forecast_minutes + self.charge_end_time_minutes = self.base.forecast_minutes + + def alt_charge_discharge_enable(self, direction, enable): + """ + Alternative enable and disable of timed charging for non-GE inverters """ - self.result = result - def get(self): + if self.inverter_type == "GS": + # Solis just has a single switch for both directions + # Need to check the logic of how this is called if both charging and discharging + + solax_modes = SOLAX_SOLIS_MODES_NEW if self.base.get_arg("solax_modbus_new", True) else SOLAX_SOLIS_MODES + + entity_id = self.base.get_arg("energy_control_switch", indirect=False, index=self.id) + switch = solax_modes.get(self.base.get_state_wrapper(entity_id), 0) + + if direction == "charge": + if enable: + new_switch = 35 + else: + new_switch = 33 + elif direction == "discharge": + if enable: + new_switch = 35 + else: + new_switch = 33 + else: + # ECO + new_switch = 35 + + # Find mode names + old_mode = {solax_modes[x]: x for x in solax_modes}[switch] + new_mode = {solax_modes[x]: x for x in solax_modes}[new_switch] + + if new_switch != switch: + self.base.log(f"Setting Solis Energy Control Switch to {new_switch} {new_mode} from {switch} {old_mode} for {direction} {enable}") + self.write_and_poll_option(name=entity_id, entity=entity_id, new_value=new_mode) + else: + self.base.log(f"Solis Energy Control Switch setting {switch} {new_mode} unchanged for {direction} {enable}") + + # MQTT + if direction == "charge" and enable: + self.mqtt_message("set/charge", payload=int(self.battery_rate_max_charge * MINUTE_WATT)) + elif direction == "discharge" and enable: + self.mqtt_message("set/discharge", payload=int(self.battery_rate_max_discharge * MINUTE_WATT)) + else: + self.mqtt_message("set/auto", payload="true") + + def mqtt_message(self, topic, payload): """ - Return the result + Send an MQTT message via service """ - return self.result + if self.inv_has_mqtt_api: + self.base.call_service_wrapper("mqtt/publish", qos=1, retain=True, topic=(self.inv_mqtt_topic + "/" + topic), payload=payload) + + def enable_charge_discharge_with_time_current(self, direction, enable): + """ + Enable or disable timed charge/discharge + """ + # To enable we set the current based on the required power + if enable: + power = self.base.get_arg(f"{direction}_rate", index=self.id, default=2600.0) + self.set_current_from_power(direction, power) + else: + if self.inv_charge_time_format == "H M": + # To disable we set both times to 00:00 + for x in ["start", "end"]: + for y in ["hour", "minute"]: + name = f"{direction}_{x}_{y}" + self.write_and_poll_value(name, self.base.get_arg(name, indirect=False, index=self.id), 0) + else: + self.set_current_from_power(direction, 0) + + def set_current_from_power(self, direction, power): + """ + Set the timed charge/discharge current setting by converting power to current + """ + new_current = round(power / self.battery_voltage, self.inv_current_dp) + self.write_and_poll_value(f"timed_{direction}_current", self.base.get_arg(f"timed_{direction}_current", indirect=False, index=self.id), new_current, fuzzy=1) + + def call_service_template(self, service, data): + """ + Call a service template with data + """ + service_template = self.base.args.get(service, "") + self.log("Inverter {} Call service template {} = {}".format(self.id, service, service_template)) + service_data = {} + service_name = "" + if service_template: + if isinstance(service_template, str): + service_name = service + service_data = data + else: + for key in service_template: + if key == "service": + service_name = service_template[key] + else: + value = service_template[key] + value = self.base.resolve_arg(service_template, value, indirect=False, index=self.id, default="", extra_args=data) + if value: + service_data[key] = value + + if service_name: + service_name = service_name.replace(".", "/") + self.log("Inverter {} Call service {} with data {}".format(self.id, service_name, service_data)) + self.base.call_service_wrapper(service_name, **service_data) + else: + self.log("Warn: Inverter {} unable to find service name for {}".format(self.id, service)) + else: + self.log("Warn: Inverter {} unable to find service template for {}".format(self.id, service)) + + def adjust_charge_immediate(self, target_soc): + """ + Adjust from charging or not charging based on passed target soc + """ + if self.inv_has_service_api: + if target_soc > 0: + service_data = { + "device_id": self.base.get_arg("device_id", index=self.id, default=""), + "target_soc": target_soc, + "power": int(self.battery_rate_max_charge * MINUTE_WATT), + } + self.call_service_template("charge_start_service", service_data) + else: + service_data = {"device_id": self.base.get_arg("device_id", index=self.id, default="")} + self.call_service_template("charge_stop_service", service_data) + + def adjust_discharge_immediate(self, target_soc): + """ + Adjust from discharging or not discharging based on passed target soc + """ + if self.inv_has_service_api: + if target_soc > 0: + service_data = { + "device_id": self.base.get_arg("device_id", index=self.id, default=""), + "target_soc": target_soc, + "power": int(self.battery_rate_max_discharge * MINUTE_WATT), + } + self.call_service_template("discharge_start_service", service_data) + else: + service_data = {"device_id": self.base.get_arg("device_id", index=self.id, default="")} + self.call_service_template("charge_stop_service", service_data) + + def adjust_charge_window(self, charge_start_time, charge_end_time, minutes_now): + """ + Adjust the charging window times (start and end) in GivTCP + + Inverter Class Parameters + ========================= + + Parameter Type Units + ---------- ---- ----- + self.charge_enable_time boole + + + Output Entities: + ================ + + Config arg Type Units + ---------- ---- ----- + scheduled_charge_enable bool + charge_start_time string + charge_end_time string + *charge_start_hour int + *charge_start_minute int + *charge_end_hour int + *charge_end_minute int + *charge_discharge_update_button button + + """ + + if SIMULATE: + old_start = self.base.sim_charge_start_time + old_end = self.base.sim_charge_end_time + old_charge_schedule_enable = self.base.sim_charge_schedule_enable + else: + if self.rest_data: + old_start = self.rest_data["Timeslots"]["Charge_start_time_slot_1"] + old_end = self.rest_data["Timeslots"]["Charge_end_time_slot_1"] + old_charge_schedule_enable = self.rest_data["Control"]["Enable_Charge_Schedule"] + elif "charge_start_time" in self.base.args: + old_start = self.base.get_arg("charge_start_time", index=self.id) + old_end = self.base.get_arg("charge_end_time", index=self.id) + old_charge_schedule_enable = self.base.get_arg("scheduled_charge_enable", "on", index=self.id) + else: + self.log("Warn: Inverter {} unable read charge window as neither REST or discharge_start_time".format(self.id)) + + # Apply clock skew + charge_start_time += timedelta(seconds=self.base.inverter_clock_skew_start * 60) + charge_end_time += timedelta(seconds=self.base.inverter_clock_skew_end * 60) + + # Convert to string + new_start = charge_start_time.strftime("%H:%M:%S") + new_end = charge_end_time.strftime("%H:%M:%S") + + # Disable scheduled charge during change of window to avoid a blip in charging if not required + have_disabled = False + in_new_window = False + + # Work out window time in minutes from midnight + new_start_minutes = charge_start_time.hour * 60 + charge_start_time.minute + new_end_minutes = charge_end_time.hour * 60 + charge_end_time.minute + + # If we are in the new window no need to disable + if minutes_now >= new_start_minutes and minutes_now < new_end_minutes: + in_new_window = True + + # Some inverters have an idle time setting + self.adjust_idle_time(charge_start=new_start, charge_end=new_end) + + # Disable charging if required, for REST no need as we change start and end together anyhow + if not in_new_window and not self.rest_data and ((new_start != old_start) or (new_end != old_end)) and self.inv_has_charge_enable_time: + self.disable_charge_window(notify=False) + have_disabled = True + if new_start != old_start: + if SIMULATE: + self.base.sim_charge_start_time = new_start + self.base.log("Simulate sim_charge_start_time now {}".format(new_start)) + else: + if self.rest_data: + pass # REST will be written as start/end together + elif "charge_start_time" in self.base.args: + # Always write to this as it is the GE default + entity_id_start = self.base.get_arg("charge_start_time", indirect=False, index=self.id) + if self.inv_charge_time_entity_is_option: + self.write_and_poll_option("charge_start_time", entity_id_start, new_start) + else: + self.write_and_poll_value("charge_start_time", entity_id_start, new_start) + + if self.inv_charge_time_format == "H M": + # If the inverter uses hours and minutes then write to these entities too + self.write_and_poll_value("charge_start_hour", self.base.get_arg("charge_start_hour", indirect=False, index=self.id), int(new_start[:2])) + self.write_and_poll_value("charge_start_minute", self.base.get_arg("charge_start_minute", indirect=False, index=self.id), int(new_start[3:5])) + else: + self.log("Warn: Inverter {} unable write charge window start as neither REST or charge_start_time are set".format(self.id)) + + # Program end slot + if new_end != old_end: + if SIMULATE: + self.base.sim_charge_end_time = new_end + self.base.log("Simulate sim_charge_end_time now {}".format(new_end)) + else: + if self.rest_data: + pass # REST will be written as start/end together + elif "charge_end_time" in self.base.args: + # Always write to this as it is the GE default + entity_id_end = self.base.get_arg("charge_end_time", indirect=False, index=self.id) + if self.inv_charge_time_entity_is_option: + self.write_and_poll_option("charge_end_time", entity_id_end, new_end) + else: + self.write_and_poll_value("charge_end_time", entity_id_end, new_end) + + if self.inv_charge_time_format == "H M": + self.write_and_poll_value("charge_end_hour", self.base.get_arg("charge_end_hour", indirect=False, index=self.id), int(new_end[:2])) + self.write_and_poll_value("charge_end_minute", self.base.get_arg("charge_end_hour", indirect=False, index=self.id), int(new_end[3:5])) + else: + self.log("Warn: Inverter {} unable write charge window end as neither REST, charge_end_hour or charge_end_time are set".format(self.id)) + + if new_start != old_start or new_end != old_end: + if self.rest_data and not SIMULATE: + self.rest_setChargeSlot1(new_start, new_end) + + # For Solis inverters we also have to press the update_charge_discharge button to send the times to the inverter + if self.inv_time_button_press: + entity_id = self.base.get_arg("charge_discharge_update_button", indirect=False, index=self.id) + self.press_and_poll_button(entity_id) + + if self.base.set_inverter_notify and not SIMULATE: + self.base.call_notify("Predbat: Inverter {} Charge window change to: {} - {} at {}".format(self.id, new_start, new_end, self.base.time_now_str())) + self.base.log("Inverter {} Updated start and end charge window to {} - {} (old {} - {})".format(self.id, new_start, new_end, old_start, old_end)) + + if old_charge_schedule_enable == "off" or old_charge_schedule_enable == "disable" or have_disabled: + if not SIMULATE: + # Enable scheduled charge if not turned on + if self.rest_data: + self.rest_enableChargeSchedule(True) + elif "scheduled_charge_enable" in self.base.args: + self.write_and_poll_switch("scheduled_charge_enable", self.base.get_arg("scheduled_charge_enable", indirect=False, index=self.id), True) + if not self.inv_has_charge_enable_time and (self.inv_output_charge_control == "current"): + self.enable_charge_discharge_with_time_current("charge", True) + else: + self.log("Warn: Inverter {} unable write charge window enable as neither REST or scheduled_charge_enable are set".format(self.id)) + + # Only notify if it's a real change and not a temporary one + if (old_charge_schedule_enable == "off" or old_charge_schedule_enable == "disable") and self.base.set_inverter_notify: + self.base.call_notify("Predbat: Inverter {} Enabling scheduled charging at {}".format(self.id, self.base.time_now_str())) + else: + self.base.sim_charge_schedule_enable = "on" + + self.charge_enable_time = True + + if old_charge_schedule_enable == "off" or old_charge_schedule_enable == "disable": + self.base.log("Inverter {} Turning on scheduled charge".format(self.id)) + + def press_and_poll_button(self, entity_id): + """ + Call a button press service (Solis) and wait for the data to update + """ + for retry in range(6): + self.base.call_service_wrapper("button/press", entity_id=entity_id) + time.sleep(self.inv_write_and_poll_sleep) + time_pressed = datetime.strptime(self.base.get_state_wrapper(entity_id, refresh=True), TIME_FORMAT_SECONDS) + + if (pytz.timezone("UTC").localize(datetime.now()) - time_pressed).seconds < 10: + self.base.log(f"Successfully pressed button {entity_id} on Inverter {self.id}") + return True + self.base.log(f"Warn: Inverter {self.id} Trying to press {entity_id} didn't complete") + self.base.record_status(f"Warn: Inverter {self.id} Trying to press {entity_id} didn't complete") + return False + + def rest_readData(self, api="readData", retry=True): + """ + Get inverter status + + :param api: The API endpoint to retrieve data from (default is "readData") + :return: The JSON response containing the inverter status, or None if there was an error + """ + url = self.rest_api + "/" + api + try: + r = requests.get(url) + except Exception as e: + self.base.log("Error: Exception raised {}".format(e)) + r = None + + if r and (r.status_code == 200): + json = r.json() + if "Control" in json: + return json + else: + if retry: + # If this is the first call in error then try to re-read the data + return self.rest_runAll() + else: + self.base.log("Warn: Inverter {} read bad REST data from {} - REST will be disabled".format(self.id, url)) + self.base.record_status("Inverter {} read bad REST data from {} - REST will be disabled".format(self.id, url), had_errors=True) + return None + else: + self.base.log("Warn: Inverter {} unable to read REST data from {} - REST will be disabled".format(self.id, url)) + self.base.record_status("Inverter {} unable to read REST data from {} - REST will be disabled".format(self.id, url), had_errors=True) + return None + + def rest_runAll(self, old_data=None): + """ + Updated and get inverter status + """ + new_data = self.rest_readData(api="runAll", retry=False) + if new_data: + return new_data + else: + return old_data + + def rest_setChargeTarget(self, target): + """ + Configure charge target % via REST + """ + target = int(target) + url = self.rest_api + "/setChargeTarget" + data = {"chargeToPercent": target} + for retry in range(5): + r = requests.post(url, json=data) + # time.sleep(10) + self.rest_data = self.rest_runAll(self.rest_data) + if float(self.rest_data["Control"]["Target_SOC"]) == target: + self.base.log("Inverter {} charge target {} via REST successful on retry {}".format(self.id, target, retry)) + return True + + self.base.log("Warn: Inverter {} charge target {} via REST failed".format(self.id, target)) + self.base.record_status("Warn: Inverter {} REST failed to setChargeTarget".format(self.id), had_errors=True) + return False + + def rest_setChargeRate(self, rate): + """ + Configure charge target % via REST + """ + rate = int(rate) + url = self.rest_api + "/setChargeRate" + data = {"chargeRate": rate} + for retry in range(5): + r = requests.post(url, json=data) + # time.sleep(10) + self.rest_data = self.rest_runAll(self.rest_data) + new = int(self.rest_data["Control"]["Battery_Charge_Rate"]) + if abs(new - rate) < (self.battery_rate_max_charge * MINUTE_WATT / 12): + self.base.log("Inverter {} set charge rate {} via REST successful on retry {}".format(self.id, rate, retry)) + return True + + self.base.log("Warn: Inverter {} set charge rate {} via REST failed got {}".format(self.id, rate, self.rest_data["Control"]["Battery_Charge_Rate"])) + self.base.record_status("Warn: Inverter {} REST failed to setChargeRate".format(self.id), had_errors=True) + return False + + def rest_setDischargeRate(self, rate): + """ + Configure charge target % via REST + """ + rate = int(rate) + url = self.rest_api + "/setDischargeRate" + data = {"dischargeRate": rate} + for retry in range(5): + r = requests.post(url, json=data) + # time.sleep(10) + self.rest_data = self.rest_runAll(self.rest_data) + new = int(self.rest_data["Control"]["Battery_Discharge_Rate"]) + if abs(new - rate) < (self.battery_rate_max_discharge * MINUTE_WATT / 25): + self.base.log("Inverter {} set discharge rate {} via REST successful on retry {}".format(self.id, rate, retry)) + return True + + self.base.log("Warn: Inverter {} set discharge rate {} via REST failed got {}".format(self.id, rate, self.rest_data["Control"]["Battery_Discharge_Rate"])) + self.base.record_status( + "Warn: Inverter {} REST failed to setDischargeRate to {} got {}".format(self.id, rate, self.rest_data["Control"]["Battery_Discharge_Rate"]), had_errors=True + ) + return False + + def rest_setBatteryMode(self, inverter_mode): + """ + Configure invert mode via REST + """ + url = self.rest_api + "/setBatteryMode" + data = {"mode": inverter_mode} + + for retry in range(5): + r = requests.post(url, json=data) + # time.sleep(10) + self.rest_data = self.rest_runAll(self.rest_data) + if inverter_mode == self.rest_data["Control"]["Mode"]: + self.base.log("Set inverter {} mode {} via REST successful on retry {}".format(self.id, inverter_mode, retry)) + return True + + self.base.log("Warn: Set inverter {} mode {} via REST failed".format(self.id, inverter_mode)) + self.base.record_status("Warn: Inverter {} REST failed to setBatteryMode".format(self.id), had_errors=True) + return False + + def rest_setReserve(self, target): + """ + Configure reserve % via REST + """ + target = int(target) + result = target + url = self.rest_api + "/setBatteryReserve" + data = {"reservePercent": target} + for retry in range(5): + r = requests.post(url, json=data) + # time.sleep(10) + self.rest_data = self.rest_runAll(self.rest_data) + result = int(float(self.rest_data["Control"]["Battery_Power_Reserve"])) + if result == target: + self.base.log("Set inverter {} reserve {} via REST successful on retry {}".format(self.id, target, retry)) + return True + + self.base.log("Warn: Set inverter {} reserve {} via REST failed on retry {} got {}".format(self.id, target, retry, result)) + self.base.record_status("Warn: Inverter {} REST failed to setReserve to {} got {}".format(self.id, target, result), had_errors=True) + return False + + def rest_enableChargeSchedule(self, enable): + """ + Configure reserve % via REST + """ + url = self.rest_api + "/enableChargeSchedule" + data = {"state": "enable" if enable else "disable"} + + for retry in range(5): + r = requests.post(url, json=data) + # time.sleep(10) + self.rest_data = self.rest_runAll(self.rest_data) + new_value = self.rest_data["Control"]["Enable_Charge_Schedule"] + if isinstance(new_value, str): + if new_value.lower() in ["enable", "on", "true"]: + new_value = True + else: + new_value = False + if new_value == enable: + self.base.log("Set inverter {} charge schedule {} via REST successful on retry {}".format(self.id, enable, retry)) + return True + + self.base.log("Warn: Set inverter {} charge schedule {} via REST failed got {}".format(self.id, enable, self.rest_data["Control"]["Enable_Charge_Schedule"])) + self.base.record_status("Warn: Inverter {} REST failed to enableChargeSchedule".format(self.id), had_errors=True) + return False + + def rest_setChargeSlot1(self, start, finish): + """ + Configure charge slot via REST + """ + url = self.rest_api + "/setChargeSlot1" + data = {"start": start[:5], "finish": finish[:5]} + + for retry in range(5): + r = requests.post(url, json=data) + # time.sleep(10) + self.rest_data = self.rest_runAll(self.rest_data) + if self.rest_data["Timeslots"]["Charge_start_time_slot_1"] == start and self.rest_data["Timeslots"]["Charge_end_time_slot_1"] == finish: + self.base.log("Inverter {} set charge slot 1 {} via REST successful after retry {}".format(self.id, data, retry)) + return True + + self.base.log("Warn: Inverter {} set charge slot 1 {} via REST failed".format(self.id, data)) + self.base.record_status("Warn: Inverter {} REST failed to setChargeSlot1".format(self.id), had_errors=True) + return False + + def rest_setDischargeSlot1(self, start, finish): + """ + Configure charge slot via REST + """ + url = self.rest_api + "/setDischargeSlot1" + data = {"start": start[:5], "finish": finish[:5]} + + for retry in range(5): + r = requests.post(url, json=data) + # time.sleep(10) + self.rest_data = self.rest_runAll(self.rest_data) + if self.rest_data["Timeslots"]["Discharge_start_time_slot_1"] == start and self.rest_data["Timeslots"]["Discharge_end_time_slot_1"] == finish: + self.base.log("Inverter {} Set discharge slot 1 {} via REST successful after retry {}".format(self.id, data, retry)) + return True + + self.base.log("Warn: Inverter {} Set discharge slot 1 {} via REST failed".format(self.id, data)) + self.base.record_status("Warn: Inverter {} REST failed to setDischargeSlot1".format(self.id), had_errors=True) + return False class PredBat(hass.Hass): @@ -4925,7 +9279,9 @@ def reset(self): """ Init stub """ - reset_prediction_globals() + global PRED_GLOBAL + PRED_GLOBAL["dict"] = None + self.define_service_list() self.stop_thread = False self.solcast_api_limit = None @@ -10160,6 +14516,7 @@ def download_predbat_version(self, version): if version == THIS_VERSION: self.log("Warn: Predbat update requested for the same version as we are running ({}), no update required".format(version)) return + self.log("Update Predbat to version {}".format(version)) self.expose_config("version", True, force=True, in_progress=True) tag_split = version.split(" ") @@ -10180,6 +14537,7 @@ def download_predbat_version(self, version): files = files.replace("[", "") files = files.replace("]", "") files = files.replace('"', "") + files = files.replace(" ", "") files = files.split(",") self.log("Predbat update files are {}".format(files)) break @@ -10192,6 +14550,8 @@ def download_predbat_version(self, version): self.download_predbat_file_from_github(tag, file, os.path.join(this_path, file + "." + tag)) # Kill the current threads + self.log("Kill current threads before update") + self.stop_thread = True if self.pool: self.log("Warn: Killing current threads before update...") self.pool.close() @@ -11134,3 +15494,264 @@ def run_time_loop_balance(self, cb_args): self.log("Error: " + traceback.format_exc()) self.record_status("Error: Exception raised {}".format(e)) raise e + + +class HAInterface: + """ + Direct interface to Home Assistant + """ + + def __init__(self, base): + """ + Initialize the interface to Home Assistant. + """ + self.ha_url = base.args.get("ha_url", "http://supervisor/core") + self.ha_key = base.args.get("ha_key", os.environ.get("SUPERVISOR_TOKEN", None)) + self.websocket_active = False + + self.base = base + self.log = base.log + self.state_data = {} + if not self.ha_key: + self.log("Warn: ha_key or SUPERVISOR_TOKEN not found, you can set ha_url/ha_key in apps.yaml. Will use direct HA API") + else: + check = self.api_call("/api/") + if not check: + self.log("Warn: Unable to connect directly to Home Assistant at {}, please check your configuration of ha_url/ha_key".format(self.ha_url)) + self.ha_key = None + else: + self.log("Info: Connected to Home Assistant at {}".format(self.ha_url)) + self.base.create_task(self.socketLoop()) + self.websocket_active = True + self.log("Info: Web Socket task started") + + async def socketLoop(self): + """ + Web socket loop for HA interface + """ + while True: + if self.base.stop_thread: + self.log("Info: Web socket stopping") + break + + url = "{}/api/websocket".format(self.ha_url) + self.log("Info: Start socket for url {}".format(url)) + async with ClientSession() as session: + try: + async with session.ws_connect(url) as websocket: + await websocket.send_json({"type": "auth", "access_token": self.ha_key}) + sid = 1 + + # Subscribe to all state changes + await websocket.send_json({"id": sid, "type": "subscribe_events", "event_type": "state_changed"}) + sid += 1 + + # Listen for services + await websocket.send_json({"id": sid, "type": "subscribe_events", "event_type": "call_service"}) + sid += 1 + + # Fire events to say we have registered services + for item in self.base.SERVICE_REGISTER_LIST: + await websocket.send_json( + {"id": sid, "type": "fire_event", "event_type": "service_registered", "event_data": {"service": item["service"], "domain": item["domain"]}} + ) + sid += 1 + + self.log("Info: Web Socket active") + self.base.update_pending = True # Force an update when web-socket reconnects + + async for message in websocket: + if self.base.stop_thread: + self.log("Info: Web socket stopping") + break + + if message.type == WSMsgType.TEXT: + try: + data = json.loads(message.data) + if data: + message_type = data.get("type", "") + if message_type == "event": + event_info = data.get("event", {}) + event_type = event_info.get("event_type", "") + if event_type == "state_changed": + event_data = event_info.get("data", {}) + old_state = event_data.get("old_state", {}) + new_state = event_data.get("new_state", {}) + if new_state: + self.update_state_item(new_state) + # Only trigger on value change or you get too many updates + if not old_state or (new_state.get("state", None) != old_state.get("state", None)): + await self.base.trigger_watch_list( + new_state["entity_id"], event_data.get("attribute", None), event_data.get("old_state", None), new_state + ) + elif event_type == "call_service": + service_data = event_info.get("data", {}) + await self.base.trigger_callback(service_data) + else: + self.log("Info: Web Socket unknown message {}".format(data)) + elif message_type == "result": + success = data.get("success", False) + if not success: + self.log("Warn: Web Socket result failed {}".format(data)) + elif message_type == "auth_required": + pass + elif message_type == "auth_ok": + pass + elif message_type == "auth_invalid": + self.log("Warn: Web Socket auth failed, check your ha_key setting") + self.websocket_active = False + raise Exception("Web Socket auth failed") + else: + self.log("Info: Web Socket unknown message {}".format(data)) + except Exception as e: + self.log("Error: Web Socket exception in update loop: {}".format(e)) + self.log("Error: " + traceback.format_exc()) + break + + elif message.type == WSMsgType.CLOSED: + break + elif message.type == WSMsgType.ERROR: + break + + except Exception as e: + self.log("Error: Web Socket exception in startup: {}".format(e)) + self.log("Error: " + traceback.format_exc()) + + if not self.base.stop_thread: + self.log("Warn: Web Socket closed, will try to reconnect in 5 seconds") + await asyncio.sleep(5) + + def get_state(self, entity_id=None, default=None, attribute=None, refresh=False): + """ + Get state from cached HA data (or from appDaemon if used) + """ + if not self.ha_key: + return self.base.get_state(entity_id=entity_id, default=default, attribute=attribute) + + if not entity_id: + return self.state_data + elif entity_id.lower() in self.state_data: + if refresh: + self.update_state(entity_id) + state_info = self.state_data[entity_id.lower()] + if attribute: + if attribute in state_info["attributes"]: + return state_info["attributes"][attribute] + else: + return default + else: + return state_info["state"] + else: + return default + + def update_state(self, entity_id): + """ + Update state for entity_id + """ + if not self.ha_key: + return + item = self.api_call("/api/states/{}".format(entity_id)) + if item: + self.update_state_item(item) + + def update_state_item(self, item): + """ + Update state table for item + """ + entity_id = item["entity_id"] + attributes = item["attributes"] + last_changed = item["last_changed"] + state = item["state"] + self.state_data[entity_id.lower()] = {"state": state, "attributes": attributes, "last_changed": last_changed} + + def update_states(self): + """ + Update the state data from Home Assistant. + """ + if not self.ha_key: + return + res = self.api_call("/api/states") + if res: + self.state_data = {} + for item in res: + self.update_state_item(item) + else: + self.log("Warn: Failed to update state data from HA") + + def get_history(self, sensor, now, days=30): + """ + Get the history for a sensor from Home Assistant. + + :param sensor: The sensor to get the history for. + :return: The history for the sensor. + """ + if not self.ha_key: + return self.base.get_history_ad(sensor, days=days) + + start = now - timedelta(days=days) + end = now + res = self.api_call("/api/history/period/{}".format(start.strftime(TIME_FORMAT_HA)), {"filter_entity_id": sensor, "end_time": end.strftime(TIME_FORMAT_HA)}) + return res + + def set_state(self, entity_id, state, attributes={}): + """ + Set the state of an entity in Home Assistant. + """ + if not self.ha_key: + if attributes: + return self.base.set_state(entity_id, state=state, attributes=attributes) + else: + return self.base.set_state(entity_id, state=state) + + data = {"state": state} + if attributes: + data["attributes"] = attributes + self.api_call("/api/states/{}".format(entity_id), data, post=True) + self.update_state(entity_id) + + def call_service(self, service, **kwargs): + """ + Call a service in Home Assistant. + """ + if not self.ha_key: + return self.base.call_service(service, **kwargs) + + data = {} + for key in kwargs: + data[key] = kwargs[key] + self.api_call("/api/services/{}".format(service), data, post=True) + + def api_call(self, endpoint, data_in=None, post=False): + """ + Make an API call to Home Assistant. + + :param endpoint: The API endpoint to call. + :param data_in: The data to send in the body of the request. + :param post: True if this is a POST request, False for GET. + :return: The response from the API. + """ + url = self.ha_url + endpoint + headers = { + "Authorization": "Bearer " + self.ha_key, + "Content-Type": "application/json", + "Accept": "application/json", + } + if post: + if data_in: + response = requests.post(url, headers=headers, json=data_in, timeout=TIMEOUT) + else: + response = requests.post(url, headers=headers, timeout=TIMEOUT) + else: + if data_in: + response = requests.get(url, headers=headers, params=data_in, timeout=TIMEOUT) + else: + response = requests.get(url, headers=headers, timeout=TIMEOUT) + try: + data = response.json() + except requests.exceptions.JSONDecodeError: + self.log("Warn: Failed to decode response {} from {}".format(response, url)) + data = None + except (requests.Timeout, requests.exceptions.ReadTimeout): + self.log("Warn: Timeout from {}".format(url)) + data = None + return data