# -----------------------------------------------------------------------------
# Predbat Home Battery System
# Copyright Trefor Southwell 2024 - All Rights Reserved
# This application maybe used for personal use only and not for commercial use
# -----------------------------------------------------------------------------
# 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
import sys
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
import json
THIS_VERSION = "v8.3.7"
PREDBAT_FILES = ["predbat.py", "config.py", "prediction.py", "utils.py", "inverter.py", "ha.py", "download.py", "unit_test.py"]
from download import predbat_update_move, predbat_update_download, check_install
# Sanity check the install and re-download if corrupted
if not check_install():
print("Warn: Predbat files are not installed correctly, trying to download them")
files = predbat_update_download(THIS_VERSION)
if files:
print("Downloaded files, moving into place")
predbat_update_move(THIS_VERSION, files)
else:
print("Warn: Failed to download predbat files for version {}, it may not exist or you may have network issues".format(THIS_VERSION))
sys.exit(1)
else:
print("Predbat files are installed correctly for version {}".format(THIS_VERSION))
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,
MAX_INCREMENT,
CONFIG_ROOTS,
PREDBAT_MODE_CONTROL_SOC,
PREDBAT_MODE_CONTROL_CHARGE,
PREDBAT_MODE_CONTROL_CHARGEDISCHARGE,
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
"""
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
class PredBat(hass.Hass):
"""
The battery prediction class itself
"""
def call_notify(self, message):
"""
Sync wrapper for call_notify
"""
for device in self.notify_devices:
self.call_service_wrapper("notify/" + device, message=message)
return True
def call_service_wrapper_stub2(self, service, message):
"""
Stub for 2 arg service wrapper
"""
return self.call_service_wrapper(service, message=message)
async def async_call_notify(self, message):
"""
Send HA notifications
"""
for device in self.notify_devices:
await self.run_in_executor(self.call_service_wrapper_stub2, "notify/" + device, message)
return True
def resolve_arg(self, arg, value, default=None, indirect=True, combine=False, attribute=None, index=None, extra_args=None):
"""
Resolve argument templates and state instances
"""
if isinstance(value, list) and (index is not None):
if index < len(value):
value = value[index]
else:
self.log("Warn: Out of range index {} within item {} value {}".format(index, arg, value))
value = None
index = None
if index:
self.log("Warn: Out of range index {} within item {} value {}".format(index, arg, value))
# If we have a list of items get each and add them up or return them as a list
if isinstance(value, list):
if combine:
final = 0
for item in value:
got = self.resolve_arg(arg, item, default=default, indirect=True)
try:
final += float(got)
except (ValueError, TypeError):
self.log("Warn: Return bad value {} from {} arg {}".format(got, item, arg))
self.record_status("Warn: Return bad value {} from {} arg {}".format(got, item, arg), had_errors=True)
return final
else:
final = []
for item in value:
item = self.resolve_arg(arg, item, default=default, indirect=indirect)
if isinstance(item, list):
final += item
else:
final.append(item)
return final
# Resolve templated data
for repeat in range(2):
if isinstance(value, str) and "{" in value:
try:
if extra_args:
value = value.format(**self.args, **extra_args)
else:
value = value.format(**self.args)
except KeyError:
self.log("Warn: can not resolve {} value {}".format(arg, value))
self.record_status("Warn: can not resolve {} value {}".format(arg, value), had_errors=True)
value = default
# Resolve join list by name
if isinstance(value, str) and value.startswith("+[") and value.endswith("]"):
value = self.get_arg(value[2:-1], default=default, indirect=indirect, combine=False, attribute=attribute, index=index)
# Resolve indirect instance
if indirect and isinstance(value, str) and "." in value:
if "$" in value:
value, attribute = value.split("$")
if attribute:
value = self.get_state_wrapper(entity_id=value, default=default, attribute=attribute)
else:
value = self.get_state_wrapper(entity_id=value, default=default)
return value
def get_arg(self, arg, default=None, indirect=True, combine=False, attribute=None, index=None):
"""
Argument getter that can use HA state as well as fixed values
"""
value = None
# Get From HA config
value, default = self.get_ha_config(arg, default)
# Resolve locally if no HA config
if value is None:
if (arg not in self.args) and default and (index is not None):
# Allow default to apply to all indices if there is not config item set
index = None
value = self.args.get(arg, default)
value = self.resolve_arg(arg, value, default=default, indirect=indirect, combine=combine, attribute=attribute, index=index)
if isinstance(default, float):
# Convert to float?
try:
value = float(value)
except (ValueError, TypeError):
self.log("Warn: Return bad float value {} from {} using default {}".format(value, arg, default))
self.record_status("Warn: Return bad float value {} from {}".format(value, arg), had_errors=True)
value = default
elif isinstance(default, int) and not isinstance(default, bool):
# Convert to int?
try:
value = int(float(value))
except (ValueError, TypeError):
self.log("Warn: Return bad int value {} from {} using default {}".format(value, arg, default))
self.record_status("Warn: Return bad int value {} from {}".format(value, arg), had_errors=True)
value = default
elif isinstance(default, bool) and isinstance(value, str):
# Convert to Boolean
if value.lower() in ["on", "true", "yes", "enabled", "enable", "connected"]:
value = True
else:
value = False
elif isinstance(default, list):
# Convert to list?
if not isinstance(value, list):
value = [value]
return value
def get_ge_url(self, url, headers, now_utc):
"""
Get data from GE Cloud
"""
if url in self.ge_url_cache:
stamp = self.ge_url_cache[url]["stamp"]
pdata = self.ge_url_cache[url]["data"]
age = now_utc - stamp
if age.seconds < (30 * 60):
self.log("Return cached GE data for {} age {} minutes".format(url, self.dp1(age.seconds / 60)))
return pdata
self.log("Fetching {}".format(url))
r = requests.get(url, headers=headers)
try:
data = r.json()
except requests.exceptions.JSONDecodeError:
self.log("Warn: Error downloading GE data from URL {}".format(url))
self.record_status("Warn: Error downloading GE data from cloud", debug=url, had_errors=True)
return False
self.ge_url_cache[url] = {}
self.ge_url_cache[url]["stamp"] = now_utc
self.ge_url_cache[url]["data"] = data
return data
def download_ge_data(self, now_utc):
"""
Download consumption data from GE Cloud
"""
geserial = self.get_arg("ge_cloud_serial")
gekey = self.args.get("ge_cloud_key", None)
if not geserial:
self.log("Error: GE Cloud has been enabled but ge_cloud_serial is not set to your serial")
self.record_status("Warn: GE Cloud has been enabled but ge_cloud_serial is not set to your serial", had_errors=True)
return False
if not gekey:
self.log("Error: GE Cloud has been enabled but ge_cloud_key is not set to your appkey")
self.record_status("Warn: GE Cloud has been enabled but ge_cloud_key is not set to your appkey", had_errors=True)
return False
headers = {"Authorization": "Bearer " + gekey, "Content-Type": "application/json", "Accept": "application/json"}
mdata = []
days_prev = 0
while days_prev <= self.max_days_previous:
time_value = now_utc - timedelta(days=(self.max_days_previous - days_prev))
datestr = time_value.strftime("%Y-%m-%d")
url = "https://api.givenergy.cloud/v1/inverter/{}/data-points/{}?pageSize=4096".format(geserial, datestr)
while url:
data = self.get_ge_url(url, headers, now_utc)
darray = data.get("data", None)
if darray is None:
self.log("Warn: Error downloading GE data from URL {}".format(url))
self.record_status("Warn: Error downloading GE data from cloud", debug=url)
return False
for item in darray:
timestamp = item["time"]
consumption = item["total"]["consumption"]
dimport = item["total"]["grid"]["import"]
dexport = item["total"]["grid"]["export"]
dpv = item["total"]["solar"]
new_data = {}
new_data["last_updated"] = timestamp
new_data["consumption"] = consumption
new_data["import"] = dimport
new_data["export"] = dexport
new_data["pv"] = dpv
mdata.append(new_data)
url = data["links"].get("next", None)
days_prev += 1
# Find how old the data is
item = mdata[0]
try:
last_updated_time = self.str2time(item["last_updated"])
except (ValueError, TypeError):
last_updated_time = now_utc
age = now_utc - last_updated_time
self.load_minutes_age = age.days
self.load_minutes = self.minute_data(
mdata, self.max_days_previous, now_utc, "consumption", "last_updated", backwards=True, smoothing=True, scale=self.load_scaling, clean_increment=True
)
self.import_today = self.minute_data(
mdata, self.max_days_previous, now_utc, "import", "last_updated", backwards=True, smoothing=True, scale=self.import_export_scaling, clean_increment=True
)
self.export_today = self.minute_data(
mdata, self.max_days_previous, now_utc, "export", "last_updated", backwards=True, smoothing=True, scale=self.import_export_scaling, clean_increment=True
)
self.pv_today = self.minute_data(
mdata, self.max_days_previous, now_utc, "pv", "last_updated", backwards=True, smoothing=True, scale=self.import_export_scaling, clean_increment=True
)
self.load_minutes_now = self.load_minutes.get(0, 0) - self.load_minutes.get(self.minutes_now, 0)
self.import_today_now = self.import_today.get(0, 0) - self.import_today.get(self.minutes_now, 0)
self.export_today_now = self.export_today.get(0, 0) - self.export_today.get(self.minutes_now, 0)
self.pv_today_now = self.pv_today.get(0, 0) - self.pv_today.get(self.minutes_now, 0)
self.log("Downloaded {} datapoints from GE going back {} days".format(len(self.load_minutes), self.load_minutes_age))
return True
def download_predbat_releases_url(self, url):
"""
Download release data from github, but use the cache for 2 hours
"""
# Check the cache first
now = datetime.now()
if url in self.github_url_cache:
stamp = self.github_url_cache[url]["stamp"]
pdata = self.github_url_cache[url]["data"]
age = now - stamp
if age.seconds < (120 * 60):
self.log("Using cached GITHub data for {} age {} minutes".format(url, self.dp1(age.seconds / 60)))
return pdata
try:
r = requests.get(url)
except Exception:
self.log("Warn: Unable to load data from Github URL: {}".format(url))
return []
try:
pdata = r.json()
except requests.exceptions.JSONDecodeError:
self.log("Warn: Unable to decode data from Github URL: {}".format(url))
return []
# Save to cache
self.github_url_cache[url] = {}
self.github_url_cache[url]["stamp"] = now
self.github_url_cache[url]["data"] = pdata
return pdata
def download_predbat_releases(self):
"""
Download release data
"""
global PREDBAT_UPDATE_OPTIONS
auto_update = self.get_arg("auto_update")
url = "https://api.github.com/repos/springfall2008/batpred/releases"
data = self.download_predbat_releases_url(url)
self.releases = {}
if data and isinstance(data, list):
found_latest = False
found_latest_beta = False
release = data[0]
self.releases["this"] = THIS_VERSION
self.releases["latest"] = "Unknown"
self.releases["latest_beta"] = "Unknown"
for release in data:
if release.get("tag_name", "Unknown") == THIS_VERSION:
self.releases["this_name"] = release.get("name", "Unknown")
self.releases["this_body"] = release.get("body", "Unknown")
if not found_latest and not release.get("prerelease", True):
self.releases["latest"] = release.get("tag_name", "Unknown")
self.releases["latest_name"] = release.get("name", "Unknown")
self.releases["latest_body"] = release.get("body", "Unknown")
found_latest = True
if not found_latest_beta:
self.releases["latest_beta"] = release.get("tag_name", "Unknown")
self.releases["latest_beta_name"] = release.get("name", "Unknown")
self.releases["latest_beta_body"] = release.get("body", "Unknown")
found_latest_beta = True
self.log(
"Predbat {} version {} currently running, latest version is {} latest beta {}".format(
__file__, self.releases["this"], self.releases["latest"], self.releases["latest_beta"]
)
)
PREDBAT_UPDATE_OPTIONS = ["main"]
this_tag = THIS_VERSION
new_version = False
# Find all versions for the dropdown menu
for release in data:
prerelease = release.get("prerelease", True)
tag = release.get("tag_name", None)
if tag:
if prerelease:
full_name = tag + " (beta) " + release.get("name", "")
else:
full_name = tag + " " + release.get("name", "")
PREDBAT_UPDATE_OPTIONS.append(full_name)
if this_tag == tag:
this_tag = full_name
if len(PREDBAT_UPDATE_OPTIONS) >= 10:
break
# Update the drop down menu
item = self.config_index.get("update", None)
if item:
item["options"] = PREDBAT_UPDATE_OPTIONS
item["value"] = None
# See what version we are on and auto-update
if this_tag not in PREDBAT_UPDATE_OPTIONS:
this_tag = this_tag + " (?)"
PREDBAT_UPDATE_OPTIONS.append(this_tag)
self.log("Autoupdate: Currently on unknown version {}".format(this_tag))
else:
if self.releases["this"] == self.releases["latest"]:
self.log("Autoupdate: Currently up to date")
elif self.releases["this"] == self.releases["latest_beta"]:
self.log("Autoupdate: Currently on latest beta")
else:
latest_version = self.releases["latest"] + " " + self.releases["latest_name"]
if auto_update:
self.log("Autoupdate: There is an update pending {} - auto update triggered!".format(latest_version))
self.download_predbat_version(latest_version)
else:
self.log("Autoupdate: There is an update pending {} - auto update is off".format(latest_version))
new_version = True
# Refresh the list
self.expose_config("update", this_tag)
self.expose_config("version", new_version, force=True)
else:
self.log("Warn: Unable to download Predbat version information from github, return code: {}".format(data))
self.expose_config("version", False, force=True)
return self.releases
def download_octopus_rates(self, url):
"""
Download octopus rates directly from a URL or return from cache if recent
Retry 3 times and then throw error
"""
# Check the cache first
now = datetime.now()
if url in self.octopus_url_cache:
stamp = self.octopus_url_cache[url]["stamp"]
pdata = self.octopus_url_cache[url]["data"]
age = now - stamp
if age.seconds < (30 * 60):
self.log("Return cached octopus data for {} age {} minutes".format(url, self.dp1(age.seconds / 60)))
return pdata
# Retry up to 3 minutes
for retry in range(3):
pdata = self.download_octopus_rates_func(url)
if pdata:
break
# Download failed?
if not pdata:
self.log("Warn: Unable to download Octopus data from URL {}".format(url))
self.record_status("Warn: Unable to download Octopus data from cloud", debug=url, had_errors=True)
if url in self.octopus_url_cache:
pdata = self.octopus_url_cache[url]["data"]
return pdata
else:
raise ValueError
# Cache New Octopus data
self.octopus_url_cache[url] = {}
self.octopus_url_cache[url]["stamp"] = now
self.octopus_url_cache[url]["data"] = pdata
return pdata
def futurerate_analysis(self):
"""
Analyse futurerate energy data
"""
url = None
if "futurerate_url" in self.args:
url = self.get_arg("futurerate_url", indirect=False)
self.log("Fetching futurerate data from {}".format(url))
if not url:
return {}, {}
try:
pdata = self.download_futurerate_data(url)
except ValueError:
return {}, {}
nord_tz = pytz.timezone("Europe/Oslo")
now_offset = datetime.now(nord_tz).strftime("%z")
extracted_data = {}
extracted_keys = []
array_values = []
peak_start = datetime.strptime(self.get_arg("futurerate_peak_start", "00:00:00"), "%H:%M:%S")
peak_end = datetime.strptime(self.get_arg("futurerate_peak_end", "00:00:00"), "%H:%M:%S")
peak_start_minutes = peak_start.minute + peak_start.hour * 60
peak_end_minutes = peak_end.minute + peak_end.hour * 60
if peak_end_minutes < peak_start_minutes:
peak_end_minutes += 24 * 60
peak_premium_import = self.get_arg("futurerate_peak_premium_import", 0)
peak_premium_export = self.get_arg("futurerate_peak_premium_export", 0)
self.log("Future rates - peak rate is {} - {} minutes premium import {} export {}".format(peak_start_minutes, peak_end_minutes, peak_premium_import, peak_premium_export))
if pdata and "Rows" in pdata:
for row in pdata["Rows"]:
if "Name" in row:
rstart = row.get("StartTime", "") + now_offset
rend = row.get("EndTime", "") + now_offset
rname = row.get("Name", "")
if "Columns" in row:
for column in row["Columns"]:
cname = column.get("Name", "")
cvalue = column.get("Value", "")
date_start, time_start = rstart.split("T")
date_end, time_end = rend.split("T")
if "-" in rname and "-" in cname and "," in cvalue and cname:
date_start = cname
date_end = cname
cvalue = cvalue.replace(",", ".")
cvalue = float(cvalue)
rstart = date_start + "T" + time_start
rend = date_end + "T" + time_end
TIME_FORMAT_NORD = "%d-%m-%YT%H:%M:%S%z"
time_date_start = datetime.strptime(rstart, TIME_FORMAT_NORD)
time_date_end = datetime.strptime(rend, TIME_FORMAT_NORD)
if time_date_end < time_date_start:
time_date_end += timedelta(days=1)
delta_start = time_date_start - self.midnight_utc
delta_end = time_date_end - self.midnight_utc
minutes_start = delta_start.seconds / 60
minutes_end = delta_end.seconds / 60
if minutes_end < minutes_start:
minutes_end += 24 * 60
# Convert to pence with Agile formula, starts in pounds per Megawatt hour
rate_import = (cvalue / 10) * 2.2
rate_export = (cvalue / 10) * 0.95
if minutes_start >= peak_start_minutes and minutes_end <= peak_end_minutes:
rate_import += peak_premium_import
rate_export += peak_premium_export
rate_import = min(rate_import, 95) # Cap
rate_export = max(rate_export, 0) # Cap
rate_import = rate_import * 1.05 # Vat only on import
item = {}
item["from"] = time_date_start.strftime(TIME_FORMAT)
item["to"] = time_date_end.strftime(TIME_FORMAT)
item["rate_import"] = self.dp2(rate_import)
item["rate_export"] = self.dp2(rate_export)
if time_date_start not in extracted_keys:
extracted_keys.append(time_date_start)
extracted_data[time_date_start] = item
else:
self.log("Warn: Duplicate key {} in extracted_keys".format(time_date_start))
if extracted_keys:
extracted_keys.sort()
for key in extracted_keys:
array_values.append(extracted_data[key])
self.log("Loaded {} datapoints of futurerate analysis".format(len(extracted_keys)))
mdata_import = self.minute_data(array_values, self.forecast_days + 1, self.midnight_utc, "rate_import", "from", backwards=False, to_key="to")
mdata_export = self.minute_data(array_values, self.forecast_days + 1, self.midnight_utc, "rate_export", "from", backwards=False, to_key="to")
future_data = []
minute_now_hour = int(self.minutes_now / 60) * 60
for minute in range(minute_now_hour, self.forecast_plan_hours * 60 + minute_now_hour, 60):
if mdata_import.get(minute) or mdata_export.get(minute):
future_data.append("{} => {} / {}".format(self.time_abs_str(minute), mdata_import.get(minute), mdata_export.get(minute)))
self.log("Predicted future rates: {}".format(future_data))
return mdata_import, mdata_export
def download_futurerate_data(self, url):
"""
Download futurerate data directly from a URL or return from cache if recent
Retry 3 times and then throw error
"""
# Check the cache first
now = datetime.now()
if url in self.futurerate_url_cache:
stamp = self.futurerate_url_cache[url]["stamp"]
pdata = self.futurerate_url_cache[url]["data"]
update_time_since_midnight = stamp - self.midnight
now_since_midnight = now - self.midnight
age = now - stamp
needs_update = False
# Update if last data was yesterday
if update_time_since_midnight.seconds < 0:
needs_update = True
# data updates at 11am CET so update every 30 minutes during this period
if now_since_midnight.seconds > (9.5 * 60 * 60) and now_since_midnight.seconds < (11 * 60 * 60) and age.seconds > (0.5 * 60 * 60):
needs_update = True
if age.seconds > (12 * 60 * 60):
needs_update = True
if not needs_update:
self.log("Return cached futurerate data for {} age {} minutes".format(url, self.dp1(age.seconds / 60)))
return pdata
# Retry up to 3 minutes
for retry in range(3):
pdata = self.download_futurerate_data_func(url)
if pdata:
break
# Download failed?
if not pdata:
self.log("Warn: Unable to download futurerate data from URL {}".format(url))
self.record_status("Warn: Unable to download futurerate data from cloud", debug=url, had_errors=True)
if url in self.futurerate_url_cache:
pdata = self.futurerate_url_cache[url]["data"]
return pdata
else:
raise ValueError
# Cache New Octopus data
self.futurerate_url_cache[url] = {}
self.futurerate_url_cache[url]["stamp"] = now
self.futurerate_url_cache[url]["data"] = pdata
return pdata
def download_futurerate_data_func(self, url):
"""
Download octopus rates directly from a URL
"""
mdata = {}
if self.debug_enable:
self.log("Download {}".format(url))
r = requests.get(url)
if r.status_code not in [200, 201]:
self.log("Warn: Error downloading futurerate data from URL {}, code {}".format(url, r.status_code))
self.record_status("Warn: Error downloading futurerate data from cloud", debug=url, had_errors=True)
return {}
try:
data = r.json()
except requests.exceptions.JSONDecodeError:
self.log("Warn: Error downloading futurerate data from URL {}".format(url))
self.record_status("Warn: Error downloading futurerate data from cloud", debug=url, had_errors=True)
return {}
if "data" in data:
mdata = data["data"]
else:
self.log("Warn: Error downloading futurerate data from URL {}".format(url))
self.record_status("Warn: Error downloading futurerate data from cloud", debug=url, had_errors=True)
return {}
return mdata
def download_octopus_rates_func(self, url):
"""
Download octopus rates directly from a URL
"""
mdata = []
pages = 0
while url and pages < 3:
if self.debug_enable:
self.log("Download {}".format(url))
r = requests.get(url)
if r.status_code not in [200, 201]:
self.log("Warn: Error downloading Octopus data from URL {}, code {}".format(url, r.status_code))
self.record_status("Warn: Error downloading Octopus data from cloud", debug=url, had_errors=True)
return {}
try:
data = r.json()
except requests.exceptions.JSONDecodeError:
self.log("Warn: Error downloading Octopus data from URL {}".format(url))
self.record_status("Warn: Error downloading Octopus data from cloud", debug=url, had_errors=True)
return {}
if "results" in data:
mdata += data["results"]
else:
self.log("Warn: Error downloading Octopus data from URL {}".format(url))
self.record_status("Warn: Error downloading Octopus data from cloud", debug=url, had_errors=True)
return {}
url = data.get("next", None)
pages += 1
pdata = self.minute_data(mdata, self.forecast_days + 1, self.midnight_utc, "value_inc_vat", "valid_from", backwards=False, to_key="valid_to")
return pdata
def cache_get_url(self, url, params, max_age=8 * 60):
# Get data from cache
age_minutes = 0
data = None
hash = url + "_" + hashlib.md5(str(params).encode()).hexdigest()
hash = hash.replace("/", "_")
hash = hash.replace(":", "_")
hash = hash.replace("?", "a")
hash = hash.replace("&", "b")
hash = hash.replace("*", "c")
cache_path = self.config_root + "/cache"
if not os.path.exists(cache_path):
os.makedirs(cache_path)
cache_filename = cache_path + "/" + hash + ".json"
if os.path.exists(cache_filename):
timestamp = os.path.getmtime(cache_filename)
age = datetime.now() - datetime.fromtimestamp(timestamp)
age_minutes = (age.seconds + age.days * 24 * 60) / 60
# Read cache data
with open(cache_filename) as f:
data = json.load(f)
if age_minutes < max_age:
self.log("Return cached data for {} age {} minutes".format(url, self.dp1(age_minutes)))
return data
# Perform fetch
self.log("Fetching {}".format(url))
r = requests.get(url, params=params)
if r.status_code not in [200, 201]:
self.log("Warn: Error downloading data from url {}, code {}".format(url, r.status_code))
else:
try:
data = r.json()
except requests.exceptions.JSONDecodeError as e:
self.log("Warn: Error downloading data from URL {}, error {} code {} data was {}".format(url, e, r.status_code, r.text))
if data:
self.log("Warn: Error downloading data from URL {}, using cached data age {} minutes".format(url, self.dp1(age_minutes)))
else:
self.log("Warn: Error downloading data from URL {}, no cached data".format(url))
# Store data in cache
if data:
self.log("Writing cache data for {} to cache file {}".format(url, cache_filename))
with open(cache_filename, "w") as f:
json.dump(data, f)
return data
def download_solcast_data(self):
"""
Download solcast data directly from a URL or return from cache if recent.
"""
self.solcast_api_limit = 0
self.solcast_api_used = 0
cache_path = self.config_root + "/cache"
cache_path_p = self.config_root_p + "/cache"
host = self.args.get("solcast_host", None)
api_keys = self.args.get("solcast_api_key", None)
if not api_keys or not host:
self.log("Warn: Solcast API key or host not set")
return None
# Remove trailing '/' from host URL if necessary to prevent pathnames becoming e.g. https://api.solcast.com.au//rooftop_sites
if host[-1] == "/":
host = host[0:-1]
self.solcast_data = {}
cache_file = cache_path + "/solcast.json"
cache_file_p = cache_path_p + "/solcast.json"
if os.path.exists(cache_file):
try:
with open(cache_file) as f:
self.solcast_data = json.load(f)
except Exception as e:
self.log("Warn: Error loading Solcast cache file {}, error {}".format(cache_file_p, e))
self.log("Warn: " + traceback.format_exc())
os.remove(cache_file)
if isinstance(api_keys, str):
api_keys = [api_keys]
period_data = {}
max_age = self.get_arg("solcast_poll_hours", 8.0) * 60
for api_key in api_keys:
params = {"format": "json", "api_key": api_key.strip()}
# API Limit no longer works - 15/8/24
# wait for Solcast to provide new API
#
# url = f"{host}/json/reply/GetUserUsageAllowance"
# data = self.cache_get_url(url, params, max_age=0)
# if not data:
# self.log("Warn: Solcast, could not access usage data, check your Solcast cloud settings")
# else:
# self.solcast_api_limit += data.get("daily_limit", None)
# self.solcast_api_used += data.get("daily_limit_consumed", None)
# self.log("Solcast API limit {} used {}".format(self.solcast_api_limit, self.solcast_api_used))
site_config = self.get_arg("solcast_sites", [])
if site_config:
sites = []
for site in site_config:
sites.append({"resource_id": site})
else:
url = f"{host}/rooftop_sites"
data = self.cache_get_url(url, params, max_age=max_age)
if not data:
self.log("Warn: Solcast sites could not be downloaded, try setting solcast_sites in apps.yaml instead")
continue
sites = data.get("sites", [])
for site in sites:
resource_id = site.get("resource_id", None)
if resource_id:
self.log("Fetch data for resource id {}".format(resource_id))
params = {"format": "json", "api_key": api_key.strip(), "hours": 168}
url = f"{host}/rooftop_sites/{resource_id}/forecasts"
data = self.cache_get_url(url, params, max_age=max_age)
if not data:
self.log("Warn: Solcast forecast data for site {} could not be downloaded, check your Solcast cloud settings".format(site))
continue
forecasts = data.get("forecasts", [])
for forecast in forecasts:
period_end = forecast.get("period_end", None)
if period_end:
period_end_stamp = datetime.strptime(period_end, TIME_FORMAT_SOLCAST)
period_end_stamp.replace(tzinfo=pytz.utc)
period_period = forecast.get("period", "PT30M")
period_minutes = int(period_period[2:-1])
period_start_stamp = period_end_stamp - timedelta(minutes=period_minutes)
pv50 = forecast.get("pv_estimate", 0) / 60 * period_minutes
pv10 = forecast.get("pv_estimate10", forecast.get("pv_estimate", 0)) / 60 * period_minutes
pv90 = forecast.get("pv_estimate90", forecast.get("pv_estimate", 0)) / 60 * period_minutes
data_item = {"period_start": period_start_stamp.strftime(TIME_FORMAT), "pv_estimate": pv50, "pv_estimate10": pv10, "pv_estimate90": pv90}
if period_start_stamp in period_data:
period_data[period_start_stamp]["pv_estimate"] += pv50
period_data[period_start_stamp]["pv_estimate10"] += pv10
period_data[period_start_stamp]["pv_estimate90"] += pv90
else:
period_data[period_start_stamp] = data_item
# Merge the new data into the cached data
new_data = {}
for key in period_data:
self.solcast_data[key.strftime(TIME_FORMAT)] = period_data[key]
# Prune old data from the cache
for key_txt in self.solcast_data:
key = datetime.strptime(key_txt, TIME_FORMAT)
if key >= self.midnight_utc:
new_data[key_txt] = self.solcast_data[key_txt]
self.solcast_data = new_data
# Save to cache file
with open(cache_file, "w") as f:
json.dump(self.solcast_data, f)
# Fetch the final cached data as timestamps
period_data = {}
for key_txt in self.solcast_data:
key = datetime.strptime(key_txt, TIME_FORMAT)
period_data[key] = self.solcast_data[key_txt]
# Sort data and return
sorted_data = []
if period_data:
period_keys = list(period_data.keys())
period_keys.sort()
for key in period_keys:
sorted_data.append(period_data[key])
self.log("Solcast returned {} data points".format(len(sorted_data)))
return sorted_data
def minutes_to_time(self, updated, now):
"""
Compute the number of minutes between a time (now) and the updated time
"""
timeday = updated - now
minutes = int(timeday.seconds / 60) + int(timeday.days * 60 * 24)
return minutes
def str2time(self, str):
if "." in str:
tdata = datetime.strptime(str, TIME_FORMAT_SECONDS)
elif "T" in str:
tdata = datetime.strptime(str, TIME_FORMAT)
else:
tdata = datetime.strptime(str, TIME_FORMAT_OCTOPUS)
return tdata
def load_car_energy(self, now_utc):
"""
Load previous car charging energy data
"""
self.car_charging_energy = {}
if "car_charging_energy" in self.args:
self.car_charging_energy = self.minute_data_import_export(now_utc, "car_charging_energy", scale=self.car_charging_energy_scale, required_unit="kWh")
else:
self.log("Car charging hold {} threshold {}".format(self.car_charging_hold, self.car_charging_threshold * 60.0))
return self.car_charging_energy
def get_history_ad(self, entity_id, days=30):
"""
Wrapper for AppDaemon get history
"""
return self.get_history(entity_id=entity_id, days=days)
def get_state_wrapper(self, entity_id=None, default=None, attribute=None, refresh=False):
"""
Wrapper function to get state from HA
"""
return self.ha_interface.get_state(entity_id=entity_id, default=default, attribute=attribute, refresh=refresh)
def set_state_wrapper(self, entity_id, state, attributes={}):
"""
Wrapper function to get state from HA
"""
return self.ha_interface.set_state(entity_id, state, attributes=attributes)
def call_service_wrapper(self, service, **kwargs):
"""
Wrapper function to call a HA service
"""
return self.ha_interface.call_service(service, **kwargs)
def get_history_wrapper(self, entity_id, days=30):
"""
Wrapper function to get history from HA
"""
history = self.ha_interface.get_history(entity_id, days=days, now=self.now)
if history is None:
self.log("Error: Failure to fetch history for {}".format(entity_id))
raise ValueError
else:
return history
def minute_data_import_export(self, now_utc, key, scale=1.0, required_unit=None, increment=True, smoothing=True):
"""
Download one or more entities for import/export data
"""
if "." not in key:
entity_ids = self.get_arg(key, indirect=False)
else:
entity_ids = key
if isinstance(entity_ids, str):
entity_ids = [entity_ids]
import_today = {}
for entity_id in entity_ids:
try:
history = self.get_history_wrapper(entity_id=entity_id, days=self.max_days_previous)
except (ValueError, TypeError):
history = []
if history:
import_today = self.minute_data(
history[0],
self.max_days_previous,
now_utc,
"state",
"last_updated",
backwards=True,
smoothing=smoothing,
scale=scale,
clean_increment=increment,
accumulate=import_today,
required_unit=required_unit,
)
else:
self.log("Error: Unable to fetch history for {}".format(entity_id))
self.record_status("Error: Unable to fetch history from {}".format(entity_id), had_errors=True)
raise ValueError
return import_today
def minute_data_load(self, now_utc, entity_name, max_days_previous, required_unit=None):
"""
Download one or more entities for load data
"""
entity_ids = self.get_arg(entity_name, indirect=False)
if isinstance(entity_ids, str):
entity_ids = [entity_ids]
load_minutes = {}
age_days = None
for entity_id in entity_ids:
try:
history = self.get_history_wrapper(entity_id=entity_id, days=max_days_previous)
except ValueError:
history = []
if history:
item = history[0][0]
try:
last_updated_time = self.str2time(item["last_updated"])
except (ValueError, TypeError):
last_updated_time = now_utc
age = now_utc - last_updated_time
if age_days is None:
age_days = age.days
else:
age_days = min(age_days, age.days)
load_minutes = self.minute_data(
history[0],
max_days_previous,
now_utc,
"state",
"last_updated",
backwards=True,
smoothing=True,
scale=self.load_scaling,
clean_increment=True,
accumulate=load_minutes,
required_unit=required_unit,
)
else:
self.log("Error: Unable to fetch history for {}".format(entity_id))
self.record_status("Error: Unable to fetch history from {}".format(entity_id), had_errors=True)
raise ValueError
if age_days is None:
age_days = 0
return load_minutes, age_days
def minute_data_state(self, history, days, now, state_key, last_updated_key):
"""
Get historical data for state (e.g. predbat status)
"""
mdata = {}
prev_last_updated_time = None
last_state = "unknown"
newest_state = 0
last_state = 0
newest_age = 999999
if not history:
self.log("Warning, empty history passed to minute_data_state, ignoring (check your settings)...")
return mdata
# Process history
for item in history:
# Ignore data without correct keys
if state_key not in item:
continue
if last_updated_key not in item:
continue
# Unavailable or bad values
if item[state_key] == "unavailable" or item[state_key] == "unknown":
continue
state = item[state_key]
last_updated_time = self.str2time(item[last_updated_key])
# Update prev to the first if not set
if not prev_last_updated_time:
prev_last_updated_time = last_updated_time
last_state = state
timed = now - last_updated_time
timed_to = now - prev_last_updated_time
minutes_to = int(timed_to.seconds / 60) + int(timed_to.days * 60 * 24)
minutes = int(timed.seconds / 60) + int(timed.days * 60 * 24)
minute = minutes
while minute < minutes_to:
mdata[minute] = last_state
minute += 1
# Store previous state
prev_last_updated_time = last_updated_time
last_state = state
if minutes < newest_age:
newest_age = minutes
newest_state = state
state = newest_state
for minute in range(0, 60 * 24 * days):
rindex = 60 * 24 * days - minute - 1
state = mdata.get(rindex, state)
mdata[rindex] = state
minute += 1
return mdata
def minute_data(
self,
history,
days,
now,
state_key,
last_updated_key,
backwards=False,
to_key=None,
smoothing=False,
clean_increment=False,
divide_by=0,
scale=1.0,
accumulate=[],
adjust_key=None,
spreading=None,
required_unit=None,
):
"""
Turns data from HA into a hash of data indexed by minute with the data being the value
Can be backwards in time for history (N minutes ago) or forward in time (N minutes in the future)
"""
mdata = {}
adata = {}
newest_state = 0
last_state = 0
newest_age = 999999
prev_last_updated_time = None
max_increment = MAX_INCREMENT
# Check history is valid
if not history:
self.log("Warning, empty history passed to minute_data, ignoring (check your settings)...")
return mdata
# Process history
for item in history:
# Ignore data without correct keys
if state_key not in item:
continue
if last_updated_key not in item:
continue
# Unavailable or bad values
if item[state_key] == "unavailable" or item[state_key] == "unknown":
continue
# Get the numerical key and the timestamp and ignore if in error
try:
state = float(item[state_key]) * scale
last_updated_time = self.str2time(item[last_updated_key])
except (ValueError, TypeError):
continue
# Find and converter units
if required_unit and ("attributes" in item):
if "unit_of_measurement" in item["attributes"]:
unit = item["attributes"]["unit_of_measurement"]
if unit != required_unit:
if required_unit in ["kW", "kWh", "kg", "kg/kWh"] and unit in ["W", "Wh", "g", "g/kWh"]:
state = state / 1000.0
elif required_unit in ["W", "Wh", "g", "g/kWh"] and unit in ["kW", "kWh", "kg", "kg/kWh"]:
state = state * 1000.0
else:
# Ignore data in wrong units if we can't converter
continue
# Divide down the state if required
if divide_by:
state /= divide_by
# Update prev to the first if not set
if not prev_last_updated_time:
prev_last_updated_time = last_updated_time
last_state = state
# Intelligent adjusted?
if adjust_key:
adjusted = item.get(adjust_key, False)
else:
adjusted = False
# Work out end of time period
# If we don't get it assume it's to the previous update, this is for historical data only (backwards)
if to_key:
to_value = item[to_key]
if not to_value:
to_time = now + timedelta(minutes=24 * 60 * self.forecast_days)
else:
to_time = self.str2time(item[to_key])
else:
if backwards:
to_time = prev_last_updated_time
else:
if smoothing:
to_time = last_updated_time
last_updated_time = prev_last_updated_time
else:
to_time = None
if backwards:
timed = now - last_updated_time
if to_time:
timed_to = now - to_time
else:
timed = last_updated_time - now
if to_time:
timed_to = to_time - now
minutes = int(timed.seconds / 60) + int(timed.days * 60 * 24)
if to_time:
minutes_to = int(timed_to.seconds / 60) + int(timed_to.days * 60 * 24)
if minutes < newest_age:
newest_age = minutes
newest_state = state
if to_time:
minute = minutes
if minute == minutes_to:
mdata[minute] = state
else:
if smoothing:
# Reset to zero, sometimes not exactly zero
if clean_increment and (state < last_state) and ((last_state - state) >= 1.0):
while minute < minutes_to:
mdata[minute] = state
minute += 1
else:
# Incrementing data can't go backwards
if clean_increment and state < last_state:
state = last_state
# Create linear function
diff = (state - last_state) / (minutes_to - minute)
# If the spike is too big don't smooth it, it will removed in the clean function later
if clean_increment and max_increment > 0 and diff > max_increment:
diff = 0
index = 0
while minute < minutes_to:
if backwards:
mdata[minute] = state - diff * index
else:
mdata[minute] = last_state + diff * index
minute += 1
index += 1
else:
while minute < minutes_to:
if backwards:
mdata[minute] = last_state
else:
mdata[minute] = state
if adjusted:
adata[minute] = True
minute += 1
else:
if spreading:
for minute in range(minutes, minutes + spreading):
mdata[minute] = state
else:
mdata[minutes] = state
# Store previous time & state
if to_time and not backwards:
prev_last_updated_time = to_time
else:
prev_last_updated_time = last_updated_time
last_state = state
# If we only have a start time then fill the gaps with the last values
if not to_key:
state = newest_state
for minute in range(60 * 24 * days):
rindex = 60 * 24 * days - minute - 1
state = mdata.get(rindex, state)
mdata[rindex] = state
minute += 1
# Reverse data with smoothing
if clean_increment:
mdata = self.clean_incrementing_reverse(mdata, max_increment)
# Accumulate to previous data?
if accumulate:
for minute in range(60 * 24 * days):
if minute in mdata:
mdata[minute] += accumulate.get(minute, 0)
else:
mdata[minute] = accumulate.get(minute, 0)
if adjust_key:
self.io_adjusted = adata
# Rounding
for minute in mdata.keys():
mdata[minute] = self.dp4(mdata[minute])
return mdata
def minutes_since_yesterday(self, now):
"""
Calculate the number of minutes since 23:59 yesterday
"""
yesterday = now - timedelta(days=1)
yesterday_at_2359 = datetime.combine(yesterday, datetime.max.time())
difference = now - yesterday_at_2359
difference_minutes = int((difference.seconds + 59) / 60)
return difference_minutes
def dp0(self, value):
"""
Round to 0 decimal places
"""
return round(value)
def dp1(self, value):
"""
Round to 1 decimal place
"""
return round(value, 1)
def dp2(self, value):
"""
Round to 2 decimal places
"""
return round(value, 2)
def dp3(self, value):
"""
Round to 3 decimal places
"""
return round(value, 3)
def dp4(self, value):
"""
Round to 4 decimal places
"""
return round(value, 4)
def hit_charge_window(self, charge_window, start, end):
"""
Determines if the given start and end time falls within any of the charge windows.
Parameters:
charge_window (list): A list of dictionaries representing charge windows, each containing "start" and "end" keys.
start (int): The start time of the interval to check.
end (int): The end time of the interval to check.
Returns:
int: The index of the charge window that the interval falls within, or -1 if it doesn't fall within any charge window.
"""
window_n = 0
for window in charge_window:
if end > window["start"] and start < window["end"]:
return window_n
window_n += 1
return -1
def in_charge_window(self, charge_window, minute_abs):
"""
Work out if this minute is within the a charge window
Parameters:
charge_window (list): A sorted list of dictionaries representing charge windows.
Each dictionary should have "start" and "end" keys
representing the start and end minutes of the window.
minute_abs (int): The absolute minute value to check.
Returns:
int: The index of the charge window if the minute is within a window,
otherwise -1.
"""
window_n = 0
for window in charge_window:
if minute_abs >= window["start"] and minute_abs < window["end"]:
return window_n
elif window["start"] > minute_abs:
# As windows are sorted, we can stop searching once we've passed the minute
break
window_n += 1
return -1
def clean_incrementing_reverse(self, data, max_increment=0):
"""
Cleanup an incrementing sensor data that runs backwards in time to remove the
resets (where it goes back to 0) and make it always increment
"""
new_data = {}
length = max(data) + 1
increment = 0
last = data[length - 1]
for index in range(length):
rindex = length - index - 1
nxt = data.get(rindex, last)
if nxt >= last:
if (max_increment > 0) and ((nxt - last) > max_increment):
# Smooth out big spikes
pass
else:
increment += nxt - last
last = nxt
elif nxt < last:
if nxt <= 0 or ((last - nxt) >= (1.0)):
last = nxt
new_data[rindex] = increment
return new_data
def get_filtered_load_minute(self, data, minute_previous, historical, step=1):
"""
Gets a previous load minute after filtering for car charging
"""
load_yesterday_raw = 0
for offset in range(step):
if historical:
load_yesterday_raw += self.get_historical(data, minute_previous + offset)
else:
load_yesterday_raw += self.get_from_incrementing(data, minute_previous + offset)
load_yesterday = load_yesterday_raw
# Subtract car charging energy and iboost energy (if enabled)
subtract_energy = 0
for offset in range(step):
if historical:
if self.car_charging_hold and self.car_charging_energy:
subtract_energy += self.get_historical(self.car_charging_energy, minute_previous + offset)
if self.iboost_energy_subtract and self.iboost_energy_today:
subtract_energy += self.get_historical(self.iboost_energy_today, minute_previous + offset)
else:
if self.car_charging_hold and self.car_charging_energy:
subtract_energy += self.get_from_incrementing(self.car_charging_energy, minute_previous + offset)
if self.iboost_energy_subtract and self.iboost_energy_today:
subtract_energy += self.get_from_incrementing(self.iboost_energy_today, minute_previous + offset)
load_yesterday = max(0, load_yesterday - subtract_energy)
if self.car_charging_hold and (not self.car_charging_energy) and (load_yesterday >= (self.car_charging_threshold * step)):
# Car charging hold - ignore car charging in computation based on threshold
load_yesterday = max(load_yesterday - (self.car_charging_rate[0] * step / 60.0), 0)
return load_yesterday, load_yesterday_raw
def previous_days_modal_filter(self, data):
"""
Look at the data from previous days and discard the best case one
"""
total_points = len(self.days_previous)
sum_days = []
sum_days_id = {}
min_sum = 99999999
min_sum_day = 0
idx = 0
for days in self.days_previous:
use_days = max(min(days, self.load_minutes_age), 1)
sum_day = 0
full_days = 24 * 60 * (use_days - 1)
for minute in range(0, 24 * 60, PREDICT_STEP):
minute_previous = 24 * 60 - minute + full_days
load_yesterday, load_yesterday_raw = self.get_filtered_load_minute(data, minute_previous, historical=False, step=PREDICT_STEP)
sum_day += load_yesterday
sum_days.append(self.dp2(sum_day))
sum_days_id[days] = sum_day
if sum_day < min_sum:
min_sum_day = days
min_sum_day_idx = idx
min_sum = self.dp2(sum_day)
idx += 1
self.log("Historical data totals for days {} are {} - min {}".format(self.days_previous, sum_days, min_sum))
if self.load_filter_modal and total_points >= 3 and (min_sum_day > 0):
self.log("Model filter enabled - Discarding day {} as it is the lowest of the {} datapoints".format(min_sum_day, len(self.days_previous)))
del self.days_previous[min_sum_day_idx]
del self.days_previous_weight[min_sum_day_idx]
# Gap filling
gap_size = max(self.get_arg("load_filter_threshold", 30), 5)
for days in self.days_previous:
use_days = max(min(days, self.load_minutes_age), 1)
num_gaps = 0
full_days = 24 * 60 * (use_days - 1)
for minute in range(0, 24 * 60, PREDICT_STEP):
minute_previous = 24 * 60 - minute + full_days
if data.get(minute_previous, 0) == data.get(minute_previous + gap_size, 0):
num_gaps += PREDICT_STEP
# If we have some gaps
if num_gaps > 0:
average_day = sum_days_id[days]
if (average_day == 0) or (num_gaps >= 24 * 60):
self.log("Warn: Historical day {} has no data, unable to fill gaps normally using nominal 24kWh - you should fix your system!".format(days))
average_day = 24.0
else:
real_data_percent = ((24 * 60) - num_gaps) / (24 * 60)
average_day /= real_data_percent
self.log(
"Warn: Historical day {} has {} minutes of gap in the data, filled from {} kWh to make new average {} kWh (percent {}%)".format(
days, num_gaps, self.dp2(sum_days_id[days]), self.dp2(average_day), self.dp0(real_data_percent * 100.0)
)
)
# Do the filling
per_minute_increment = average_day / (24 * 60)
for minute in range(0, 24 * 60, PREDICT_STEP):
minute_previous = 24 * 60 - minute + full_days
if data.get(minute_previous, 0) == data.get(minute_previous + gap_size, 0):
for offset in range(minute_previous, 0, -1):
if offset in data:
data[offset] += per_minute_increment * PREDICT_STEP
else:
data[offset] = per_minute_increment * PREDICT_STEP
def get_historical_base(self, data, minute, base_minutes):
"""
Get historical data from base minute ago
"""
# No data?
if not data:
return 0
minute_previous = base_minutes - minute
return self.get_from_incrementing(data, minute_previous)
def get_historical(self, data, minute):
"""
Get historical data across N previous days in days_previous array based on current minute
"""
total = 0
total_weight = 0
this_point = 0
# No data?
if not data:
return 0
for days in self.days_previous:
use_days = max(min(days, self.load_minutes_age), 1)
weight = self.days_previous_weight[this_point]
full_days = 24 * 60 * (use_days - 1)
minute_previous = 24 * 60 - minute + full_days
value = self.get_from_incrementing(data, minute_previous)
total += value * weight
total_weight += weight
this_point += 1
# Zero data?
if total_weight == 0:
return 0
else:
return total / total_weight
def get_from_incrementing(self, data, index, backwards=True):
"""
Get a single value from an incrementing series e.g. kWh today -> kWh this minute
"""
while index < 0:
index += 24 * 60
if backwards:
return max(data.get(index, 0) - data.get(index + 1, 0), 0)
else:
return max(data.get(index + 1, 0) - data.get(index, 0), 0)
def record_length(self, charge_window, charge_limit, best_price):
"""
Limit the forecast length to either the total forecast duration or the start of the last window that falls outside the forecast
"""
next_charge_start = self.forecast_minutes + self.minutes_now
if charge_window:
for window_n in range(len(charge_window)):
if charge_limit[window_n] > 0 and charge_window[window_n]["average"] <= best_price:
next_charge_start = charge_window[window_n]["start"]
if next_charge_start < self.minutes_now:
next_charge_start = charge_window[window_n]["end"]
break
end_record = min(self.forecast_plan_hours * 60 + next_charge_start, self.forecast_minutes + self.minutes_now)
max_windows = self.max_charge_windows(end_record, charge_window)
if len(charge_window) > max_windows:
end_record = min(end_record, charge_window[max_windows]["start"])
# If we are within this window then push to the end of it
if end_record < self.minutes_now:
end_record = charge_window[max_windows]["end"]
self.log("Calculated end_record as {}".format(self.time_abs_str(end_record)))
return end_record - self.minutes_now
def max_charge_windows(self, end_record_abs, charge_window):
"""
Work out how many charge windows the time period covers
"""
charge_windows = 0
window_n = 0
for window in charge_window:
if end_record_abs >= window["end"]:
charge_windows = window_n + 1
window_n += 1
return charge_windows
def record_status(self, message, debug="", had_errors=False, notify=False, extra=""):
"""
Records status to HA sensor
"""
if not extra:
extra = ""
if notify and self.previous_status != message and self.set_status_notify:
self.call_notify("Predbat status change to: " + message + extra)
self.dashboard_item(
self.prefix + ".status",
state=message,
attributes={
"friendly_name": "Status",
"detail": extra,
"icon": "mdi:information",
"last_updated": str(datetime.now()),
"debug": debug,
"version": THIS_VERSION,
"error": (had_errors or self.had_errors),
},
)
self.log("Info: record_status {}".format(message + extra))
self.previous_status = message
if had_errors:
self.had_errors = True
def scenario_summary_title(self, record_time):
txt = ""
minute_start = self.minutes_now - self.minutes_now % 30
for minute_absolute in range(minute_start, self.forecast_minutes + minute_start, 30):
this_minute_absolute = max(minute_absolute, self.minutes_now)
minute_timestamp = self.midnight_utc + timedelta(seconds=60 * this_minute_absolute)
dstamp = minute_timestamp.strftime(TIME_FORMAT)
stamp = minute_timestamp.strftime("%H:%M")
if txt:
txt += ", "
txt += "%8s" % str(stamp)
if record_time[dstamp] > 0:
break
return txt
def scenario_summary(self, record_time, datap):
txt = ""
minute_start = self.minutes_now - self.minutes_now % 30
for minute_absolute in range(minute_start, self.forecast_minutes + minute_start, 30):
this_minute_absolute = max(minute_absolute, self.minutes_now)
minute_timestamp = self.midnight_utc + timedelta(seconds=60 * this_minute_absolute)
stamp = minute_timestamp.strftime(TIME_FORMAT)
value = datap[stamp]
if not isinstance(value, str):
value = self.dp2(value)
if value > 10000:
value = self.dp0(value)
if txt:
txt += ", "
txt += "%8s" % str(value)
if record_time[stamp] > 0:
break
return txt
def scenario_summary_state(self, record_time):
txt = ""
minute_start = self.minutes_now - self.minutes_now % 30
for minute_absolute in range(minute_start, self.forecast_minutes + minute_start, 30):
minute_relative_start = max(minute_absolute - self.minutes_now, 0)
minute_relative_end = minute_relative_start + 30
this_minute_absolute = max(minute_absolute, self.minutes_now)
minute_timestamp = self.midnight_utc + timedelta(seconds=60 * this_minute_absolute)
stamp = minute_timestamp.strftime(TIME_FORMAT)
value = ""
charge_window_n = -1
for try_minute in range(this_minute_absolute, minute_absolute + 30, 5):
charge_window_n = self.in_charge_window(self.charge_window_best, try_minute)
if charge_window_n >= 0:
break
discharge_window_n = -1
for try_minute in range(this_minute_absolute, minute_absolute + 30, 5):
discharge_window_n = self.in_charge_window(self.discharge_window_best, try_minute)
if discharge_window_n >= 0:
break
soc_percent = calc_percent_limit(self.predict_soc_best.get(minute_relative_start, 0.0), self.soc_max)
soc_percent_end = calc_percent_limit(self.predict_soc_best.get(minute_relative_end, 0.0), self.soc_max)
soc_percent_max = max(soc_percent, soc_percent_end)
soc_percent_min = min(soc_percent, soc_percent_end)
if charge_window_n >= 0 and discharge_window_n >= 0:
value = "Chrg/Dis"
elif charge_window_n >= 0:
charge_target = self.charge_limit_best[charge_window_n]
if charge_target == self.reserve:
value = "FrzChrg"
else:
value = "Chrg"
elif discharge_window_n >= 0:
discharge_target = self.discharge_limits_best[discharge_window_n]
if discharge_target >= soc_percent_max:
if discharge_target == 99:
value = "FrzDis"
else:
value = "HldDis"
else:
value = "Dis"
if record_time[stamp] > 0:
break
if txt:
txt += ", "
txt += "%8s" % str(value)
return txt
def load_today_comparison(self, load_minutes, load_forecast, car_minutes, import_minutes, minutes_now, step=5):
"""
Compare predicted vs actual load
"""
load_total_pred = 0
load_total_pred_now = 0
car_total_pred = 0
car_total_actual = 0
car_value_pred = 0
car_value_actual = 0
actual_total_now = 0
actual_total_today = 0
import_ignored_load_pred = 0
import_ignored_load_actual = 0
load_predict_stamp = {}
load_actual_stamp = {}
load_predict_data = {}
total_forecast_value_pred = 0
total_forecast_value_pred_now = 0
for minute in range(0, 24 * 60, step):
import_value_today = 0
load_value_today = 0
load_value_today_raw = 0
if minute < minutes_now:
for offset in range(step):
import_value_today += self.get_from_incrementing(import_minutes, minutes_now - minute - offset - 1)
load_value_today, load_value_today_raw = self.get_filtered_load_minute(load_minutes, minutes_now - minute - 1, historical=False, step=step)
import_value_pred = 0
forecast_value_pred = 0
for offset in range(step):
import_value_pred += self.get_historical(import_minutes, minute - minutes_now + offset)
forecast_value_pred += self.get_from_incrementing(load_forecast, minute + offset, backwards=False)
if self.load_forecast_only:
load_value_pred, load_value_pred_raw = (0, 0)
else:
load_value_pred, load_value_pred_raw = self.get_filtered_load_minute(load_minutes, minute - minutes_now, historical=True, step=step)
# Add in forecast load
load_value_pred += forecast_value_pred
load_value_pred_raw += forecast_value_pred
# Ignore periods of import as assumed to be deliberate (battery charging periods overnight for example)
car_value_actual = load_value_today_raw - load_value_today
car_value_pred = load_value_pred_raw - load_value_pred
if minute < minutes_now and import_value_today >= load_value_today_raw:
import_ignored_load_actual += load_value_today
load_value_today = 0
import_ignored_load_pred += load_value_pred
load_value_pred = 0
# Only count totals until now
if minute < minutes_now:
load_total_pred_now += load_value_pred
car_total_pred += car_value_pred
actual_total_now += load_value_today
car_total_actual += car_value_actual
actual_total_today += load_value_today
total_forecast_value_pred_now += forecast_value_pred
else:
actual_total_today += load_value_pred
load_total_pred += load_value_pred
total_forecast_value_pred += forecast_value_pred
load_predict_data[minute] = load_value_pred
# Store for charts
minute_timestamp = self.midnight_utc + timedelta(seconds=60 * minute)
stamp = minute_timestamp.strftime(TIME_FORMAT)
load_predict_stamp[stamp] = self.dp3(load_total_pred)
load_actual_stamp[stamp] = self.dp3(actual_total_today)
difference = 1.0
if minutes_now >= 180 and actual_total_now >= 1.0 and actual_total_today > 0.0:
# Make a ratio only if we have enough data to consider the outcome
difference = 1.0 + ((actual_total_today - load_total_pred) / actual_total_today)
# Work out divergence
if not self.calculate_inday_adjustment:
difference_cap = 1.0
else:
# Apply damping factor to adjustment
difference_cap = (difference - 1.0) * self.metric_inday_adjust_damping + 1.0
# Cap adjustment within 1/2 to 2x
difference_cap = max(difference_cap, 0.5)
difference_cap = min(difference_cap, 2.0)
self.log(
"Today's load divergence {} % in-day adjustment {} % damping {}x".format(
self.dp2(difference * 100.0),
self.dp2(difference_cap * 100.0),
self.metric_inday_adjust_damping,
)
)
self.log(
"Today's predicted so far {} kWh with {} kWh car/iBoost excluded and {} kWh import ignored and {} forecast extra.".format(
self.dp2(load_total_pred_now),
self.dp2(car_total_pred),
self.dp2(import_ignored_load_pred),
self.dp2(total_forecast_value_pred_now),
)
)
self.log(
"Today's actual load so far {} kWh with {} kWh Car/iBoost excluded and {} kWh import ignored.".format(
self.dp2(actual_total_now),
self.dp2(car_total_actual),
self.dp2(import_ignored_load_actual),
)
)
# Create adjusted curve
load_adjusted_stamp = {}
load_adjusted = actual_total_now
for minute in range(0, 24 * 60, step):
if minute >= minutes_now:
load = load_predict_data[minute] * difference_cap
load_adjusted += load
minute_timestamp = self.midnight_utc + timedelta(seconds=60 * minute)
stamp = minute_timestamp.strftime(TIME_FORMAT)
load_adjusted_stamp[stamp] = load_adjusted
self.dashboard_item(
self.prefix + ".load_inday_adjustment",
state=self.dp2(difference_cap * 100.0),
attributes={
"damping": self.metric_inday_adjust_damping,
"friendly_name": "Load in-day adjustment factor",
"state_class": "measurement",
"unit_of_measurement": "%",
"icon": "mdi:percent",
},
)
self.dashboard_item(
self.prefix + ".load_energy_actual",
state=self.dp3(actual_total_today),
attributes={
"results": self.filtered_times(load_actual_stamp),
"friendly_name": "Load energy actual (filtered)",
"state_class": "measurement",
"unit_of_measurement": "kWh",
"icon": "mdi:percent",
},
)
self.dashboard_item(
self.prefix + ".load_energy_predicted",
state=self.dp3(load_total_pred),
attributes={
"results": self.filtered_times(load_predict_stamp),
"today": self.filtered_today(load_predict_stamp),
"friendly_name": "Load energy predicted (filtered)",
"state_class": "measurement",
"unit_of_measurement": "kWh",
"icon": "mdi:percent",
},
)
self.dashboard_item(
self.prefix + ".load_energy_adjusted",
state=self.dp3(load_adjusted),
attributes={
"results": self.filtered_times(load_adjusted_stamp),
"today": self.filtered_today(load_adjusted_stamp),
"friendly_name": "Load energy prediction adjusted",
"state_class": "measurement",
"unit_of_measurement": "kWh",
"icon": "mdi:percent",
},
)
return difference_cap
def get_load_divergence(self, minutes_now, load_minutes):
"""
Work out the divergence between peak and average load over the next period
"""
load_total = 0
load_count = 0
load_min = 99999
load_max = 0
look_over = 60 * 8
for minute in range(0, look_over, PREDICT_STEP):
load, load_raw = self.get_filtered_load_minute(load_minutes, minute, historical=True, step=PREDICT_STEP)
load *= 1000 * 60 / PREDICT_STEP
load_total += load
load_count += 1
load_min = min(load_min, load)
load_max = max(load_max, load)
load_mean = load_total / load_count
load_diff_total = 0
for minute in range(0, look_over, PREDICT_STEP):
load = 0
load, load_raw = self.get_filtered_load_minute(load_minutes, minute, historical=True, step=PREDICT_STEP)
load *= 1000 * 60 / PREDICT_STEP
load_diff = abs(load - load_mean)
load_diff *= load_diff
load_diff_total += load_diff
load_std_dev = math.sqrt(load_diff_total / load_count)
if load_mean > 0:
load_divergence = load_std_dev / load_mean / 2.0
load_divergence = min(load_divergence, 1.0)
else:
self.log("Warn: Load mean is zero, unable to calculate divergence!")
load_divergence = 0
self.log(
"Load divergence over {} hours mean {} W, min {} W, max {} W, std dev {} W, divergence {}%".format(
look_over / 60.0, self.dp2(load_mean), self.dp2(load_min), self.dp2(load_max), self.dp2(load_std_dev), self.dp2(load_divergence * 100.0)
)
)
if self.metric_load_divergence_enable:
return self.dp2(load_divergence)
else:
return None
def get_cloud_factor(self, minutes_now, pv_data, pv_data10):
"""
Work out approximated cloud factor
"""
pv_total = 0
pv_total10 = 0
for minute in range(self.forecast_minutes):
pv_total += pv_data.get(minute + minutes_now, 0.0)
pv_total10 += pv_data10.get(minute + minutes_now, 0.0)
pv_factor = None
if pv_total > 0 and (pv_total > pv_total10):
pv_diff = pv_total - pv_total10
pv_factor = self.dp2(pv_diff / pv_total) / 2.0
pv_factor = min(pv_factor, 1.0)
if self.metric_cloud_enable:
pv_factor_round = pv_factor
if pv_factor_round:
pv_factor_round = self.dp1(pv_factor_round)
self.log("PV Forecast {} kWh and 10% Forecast {} kWh pv cloud factor {}".format(self.dp1(pv_total), self.dp1(pv_total10), pv_factor_round))
return pv_factor
else:
return None
def step_data_history(
self,
item,
minutes_now,
forward,
step=PREDICT_STEP,
scale_today=1.0,
scale_fixed=1.0,
type_load=False,
load_forecast={},
cloud_factor=None,
load_scaling_dynamic=None,
base_offset=None,
):
"""
Create cached step data for historical array
"""
values = {}
cloud_diff = 0
for minute in range(0, self.forecast_minutes + 30, step):
value = 0
minute_absolute = minute + minutes_now
scaling_dynamic = 1.0
if load_scaling_dynamic:
scaling_dynamic = load_scaling_dynamic.get(minute_absolute, scaling_dynamic)
# Reset in-day adjustment for tomorrow
if (minute + minutes_now) > 24 * 60:
scale_today = 1.0
if type_load and not forward:
if self.load_forecast_only:
load_yesterday, load_yesterday_raw = (0, 0)
else:
load_yesterday, load_yesterday_raw = self.get_filtered_load_minute(item, minute, historical=True, step=step)
value += load_yesterday
else:
for offset in range(step):
if forward:
value += item.get(minute + minutes_now + offset, 0.0)
else:
if base_offset:
value += self.get_historical_base(item, minute + offset, base_offset)
else:
value += self.get_historical(item, minute + offset)
# Extra load adding in (e.g. heat pump)
load_extra = 0
if load_forecast:
for offset in range(step):
load_extra += self.get_from_incrementing(load_forecast, minute_absolute, backwards=False)
values[minute] = self.dp4((value + load_extra) * scaling_dynamic * scale_today * scale_fixed)
# Simple divergence model keeps the same total but brings PV/Load up and down every 5 minutes
if cloud_factor and cloud_factor > 0:
for minute in range(0, self.forecast_minutes, step):
cloud_on = int((minute + self.minutes_now) / 5) % 2
if cloud_on > 0:
cloud_diff += min(values[minute] * cloud_factor, values.get(minute + 5, 0) * cloud_factor)
values[minute] += cloud_diff
else:
subtract = min(cloud_diff, values[minute])
values[minute] -= subtract
cloud_diff = 0
values[minute] = self.dp4(values[minute])
return values
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 filtered_today(self, time_data, resetmidnight=False):
"""
Grab figure for today (midnight)
"""
today = self.midnight_utc
tomorrow = today + timedelta(days=1)
if resetmidnight:
tomorrow = tomorrow - timedelta(minutes=PREDICT_STEP)
tomorrow_stamp = tomorrow.strftime(TIME_FORMAT)
tomorrow_value = time_data.get(tomorrow_stamp, None)
return tomorrow_value
def filtered_times(self, time_data):
"""
Filter out duplicate values in time series data
"""
prev = None
new_data = {}
keys = list(time_data.keys())
for id in range(len(keys)):
stamp = keys[id]
value = time_data[stamp]
next_value = time_data[keys[id + 1]] if id + 1 < len(keys) else None
if prev is None or value != prev or next_value != value:
new_data[stamp] = value
prev = value
id += 1
return new_data
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, options to save the results or not to HA entity
"""
# Call the prediction model
pred = self.prediction
(
final_metric,
import_kwh_battery,
import_kwh_house,
export_kwh,
soc_min,
final_soc,
soc_min_minute,
final_battery_cycle,
final_metric_keep,
final_iboost_kwh,
final_carbon_g,
) = pred.run_prediction(charge_limit, charge_window, discharge_window, discharge_limits, pv10, end_record, save, step)
self.predict_soc = pred.predict_soc
self.car_charging_soc_next = pred.car_charging_soc_next
self.iboost_next = pred.iboost_next
self.iboost_running = pred.iboost_running
self.iboost_running_solar = pred.iboost_running_solar
self.iboost_running_full = pred.iboost_running_full
if save or self.debug_enable:
predict_soc_time = pred.predict_soc_time
first_charge = pred.first_charge
first_charge_soc = pred.first_charge_soc
predict_car_soc_time = pred.predict_car_soc_time
predict_battery_power = pred.predict_battery_power
predict_state = pred.predict_state
predict_battery_cycle = pred.predict_battery_cycle
predict_pv_power = pred.predict_pv_power
predict_grid_power = pred.predict_grid_power
predict_load_power = pred.predict_load_power
final_export_kwh = pred.final_export_kwh
export_kwh_h0 = pred.export_kwh_h0
final_load_kwh = pred.final_load_kwh
load_kwh_h0 = pred.load_kwh_h0
metric_time = pred.metric_time
record_time = pred.record_time
predict_iboost = pred.predict_iboost
predict_carbon_g = pred.predict_carbon_g
load_kwh_time = pred.load_kwh_time
pv_kwh_time = pred.pv_kwh_time
import_kwh_time = pred.import_kwh_time
export_kwh_time = pred.export_kwh_time
final_pv_kwh = pred.final_pv_kwh
export_to_first_charge = pred.export_to_first_charge
pv_kwh_h0 = pred.pv_kwh_h0
final_import_kwh = pred.final_import_kwh
final_import_kwh_house = pred.final_import_kwh_house
final_import_kwh_battery = pred.final_import_kwh_battery
hours_left = pred.hours_left
final_car_soc = pred.final_car_soc
import_kwh_h0 = pred.import_kwh_h0
predict_export = pred.predict_export
if save == "best":
self.predict_soc_best = pred.predict_soc_best
self.predict_iboost_best = pred.predict_iboost_best
self.predict_metric_best = pred.predict_metric_best
self.predict_carbon_best = pred.predict_carbon_best
if self.debug_enable or save:
self.log(
"predict {} end_record {} final soc {} kWh metric {} {} metric_keep {} min_soc {} @ {} kWh load {} pv {}".format(
save,
self.time_abs_str(end_record + self.minutes_now),
round(final_soc, 2),
round(final_metric, 2),
self.currency_symbols[1],
round(final_metric_keep, 2),
round(soc_min, 2),
self.time_abs_str(soc_min_minute),
round(final_load_kwh, 2),
round(final_pv_kwh, 2),
)
)
self.log(" [{}]".format(self.scenario_summary_title(record_time)))
self.log(" SOC: [{}]".format(self.scenario_summary(record_time, predict_soc_time)))
self.log(" BAT: [{}]".format(self.scenario_summary(record_time, predict_state)))
self.log(" LOAD: [{}]".format(self.scenario_summary(record_time, load_kwh_time)))
self.log(" PV: [{}]".format(self.scenario_summary(record_time, pv_kwh_time)))
self.log(" IMPORT: [{}]".format(self.scenario_summary(record_time, import_kwh_time)))
self.log(" EXPORT: [{}]".format(self.scenario_summary(record_time, export_kwh_time)))
if self.iboost_enable:
self.log(" IBOOST: [{}]".format(self.scenario_summary(record_time, predict_iboost)))
if self.carbon_enable:
self.log(" CARBON: [{}]".format(self.scenario_summary(record_time, predict_carbon_g)))
for car_n in range(self.num_cars):
self.log(" CAR{}: [{}]".format(car_n, self.scenario_summary(record_time, predict_car_soc_time[car_n])))
self.log(" METRIC: [{}]".format(self.scenario_summary(record_time, metric_time)))
if save == "best":
self.log(" STATE: [{}]".format(self.scenario_summary_state(record_time)))
# Save data to HA state
if save and save == "base":
self.dashboard_item(
self.prefix + ".battery_hours_left",
state=self.dp2(hours_left),
attributes={"friendly_name": "Predicted Battery Hours left", "state_class": "measurement", "unit_of_measurement": "hours", "icon": "mdi:timelapse"},
)
postfix = ""
for car_n in range(self.num_cars):
if car_n > 0:
postfix = "_" + str(car_n)
self.dashboard_item(
self.prefix + ".car_soc" + postfix,
state=self.dp2(final_car_soc[car_n] / self.car_charging_battery_size[car_n] * 100.0),
attributes={
"results": self.filtered_times(predict_car_soc_time[car_n]),
"today": self.filtered_today(predict_car_soc_time[car_n]),
"friendly_name": "Car " + str(car_n) + " battery SOC",
"state_class": "measurement",
"unit_of_measurement": "%",
"icon": "mdi:battery",
},
)
self.dashboard_item(
self.prefix + ".soc_kw_h0",
state=self.dp3(self.predict_soc[0]),
attributes={"friendly_name": "Current SOC kWh", "state_class": "measurement", "unit_of_measurement": "kWh", "icon": "mdi:battery"},
)
self.dashboard_item(
self.prefix + ".soc_kw",
state=self.dp3(final_soc),
attributes={
"results": self.filtered_times(predict_soc_time),
"today": self.filtered_today(predict_soc_time),
"friendly_name": "Predicted SOC kWh",
"state_class": "measurement",
"unit_of_measurement": "kWh",
"first_charge_kwh": first_charge_soc,
"icon": "mdi:battery",
},
)
self.dashboard_item(
self.prefix + ".battery_power",
state=self.dp3(0),
attributes={
"results": self.filtered_times(predict_battery_power),
"today": self.filtered_today(predict_battery_power),
"friendly_name": "Predicted Battery Power",
"state_class": "measurement",
"unit_of_measurement": "kW",
"icon": "mdi:battery",
},
)
self.dashboard_item(
self.prefix + ".battery_cycle",
state=self.dp3(final_battery_cycle),
attributes={
"results": self.filtered_times(predict_battery_cycle),
"today": self.filtered_today(predict_battery_cycle),
"friendly_name": "Predicted Battery Cycle",
"state_class": "measurement",
"unit_of_measurement": "kWh",
"icon": "mdi:battery",
},
)
self.dashboard_item(
self.prefix + ".pv_power",
state=self.dp3(self.pv_power / 1000.0),
attributes={
"results": self.filtered_times(predict_pv_power),
"today": self.filtered_today(predict_pv_power),
"friendly_name": "Predicted PV Power",
"state_class": "measurement",
"unit_of_measurement": "kW",
"icon": "mdi:battery",
},
)
self.dashboard_item(
self.prefix + ".grid_power",
state=self.dp3(0),
attributes={
"results": self.filtered_times(predict_grid_power),
"today": self.filtered_today(predict_grid_power),
"friendly_name": "Predicted Grid Power",
"state_class": "measurement",
"unit_of_measurement": "kW",
"icon": "mdi:battery",
},
)
self.dashboard_item(
self.prefix + ".load_power",
state=self.dp3(self.load_power / 1000.0),
attributes={
"results": self.filtered_times(predict_load_power),
"today": self.filtered_today(predict_load_power),
"friendly_name": "Predicted Load Power",
"state_class": "measurement",
"unit_of_measurement": "kW",
"icon": "mdi:battery",
},
)
self.dashboard_item(
self.prefix + ".soc_min_kwh",
state=self.dp3(soc_min),
attributes={
"time": self.time_abs_str(soc_min_minute),
"friendly_name": "Predicted minimum SOC best",
"state_class": "measurement",
"unit_of_measurement": "kWh",
"icon": "mdi:battery-arrow-down-outline",
},
)
self.dashboard_item(
self.prefix + ".export_energy",
state=self.dp3(final_export_kwh),
attributes={
"results": self.filtered_times(export_kwh_time),
"today": self.filtered_today(export_kwh_time),
"export_until_charge_kwh": export_to_first_charge,
"friendly_name": "Predicted exports",
"state_class": "measurement",
"unit_of_measurement": "kWh",
"icon": "mdi:transmission-tower-export",
},
)
self.dashboard_item(
self.prefix + ".export_energy_h0",
state=self.dp3(export_kwh_h0),
attributes={"friendly_name": "Current export kWh", "state_class": "measurement", "unit_of_measurement": "kWh", "icon": "mdi:transmission-tower-export"},
)
self.dashboard_item(
self.prefix + ".load_energy",
state=self.dp3(final_load_kwh),
attributes={
"results": self.filtered_times(load_kwh_time),
"today": self.filtered_today(load_kwh_time),
"friendly_name": "Predicted load",
"state_class": "measurement",
"unit_of_measurement": "kWh",
"icon": "mdi:home-lightning-bolt",
},
)
self.dashboard_item(
self.prefix + ".load_energy_h0",
state=self.dp3(load_kwh_h0),
attributes={"friendly_name": "Current load kWh", "state_class": "measurement", "unit_of_measurement": "kWh", "icon": "mdi:home-lightning-bolt"},
)
self.dashboard_item(
self.prefix + ".pv_energy",
state=self.dp3(final_pv_kwh),
attributes={"results": pv_kwh_time, "friendly_name": "Predicted PV", "state_class": "measurement", "unit_of_measurement": "kWh", "icon": "mdi:solar-power"},
)
self.dashboard_item(
self.prefix + ".pv_energy_h0",
state=self.dp3(pv_kwh_h0),
attributes={"friendly_name": "Current PV kWh", "state_class": "measurement", "unit_of_measurement": "kWh", "icon": "mdi:solar-power"},
)
self.dashboard_item(
self.prefix + ".import_energy",
state=self.dp3(final_import_kwh),
attributes={
"results": self.filtered_times(import_kwh_time),
"today": self.filtered_today(import_kwh_time),
"friendly_name": "Predicted imports",
"state_class": "measurement",
"unit_of_measurement": "kWh",
"icon": "mdi:transmission-tower-import",
},
)
self.dashboard_item(
self.prefix + ".import_energy_h0",
state=self.dp3(import_kwh_h0),
attributes={"friendly_name": "Current import kWh", "state_class": "measurement", "unit_of_measurement": "kWh", "icon": "mdi:transmission-tower-import"},
)
self.dashboard_item(
self.prefix + ".import_energy_battery",
state=self.dp3(final_import_kwh_battery),
attributes={
"friendly_name": "Predicted import to battery",
"state_class": "measurement",
"unit_of_measurement": "kWh",
"icon": "mdi:transmission-tower-import",
},
)
self.dashboard_item(
self.prefix + ".import_energy_house",
state=self.dp3(final_import_kwh_house),
attributes={"friendly_name": "Predicted import to house", "state_class": "measurement", "unit_of_measurement": "kWh", "icon": "mdi:transmission-tower-import"},
)
self.log("Battery has {} hours left - now at {}".format(self.dp2(hours_left), self.dp2(self.soc_kw)))
self.dashboard_item(
self.prefix + ".metric",
state=self.dp2(final_metric),
attributes={
"results": self.filtered_times(metric_time),
"today": self.filtered_today(metric_time),
"friendly_name": "Predicted metric (cost)",
"state_class": "measurement",
"unit_of_measurement": self.currency_symbols[1],
"icon": "mdi:currency-usd",
},
)
self.dashboard_item(
self.prefix + ".duration",
state=self.dp2(end_record / 60),
attributes={"friendly_name": "Prediction duration", "state_class": "measurement", "unit_of_measurement": "hours", "icon": "mdi:arrow-split-vertical"},
)
if self.carbon_enable:
self.dashboard_item(
self.prefix + ".carbon",
state=self.dp2(final_carbon_g),
attributes={
"results": self.filtered_times(predict_carbon_g),
"today": self.filtered_today(predict_carbon_g),
"friendly_name": "Predicted Carbon energy",
"state_class": "measurement",
"unit_of_measurement": "g",
"icon": "mdi:molecule-co2",
},
)
self.dashboard_item(
self.prefix + ".carbon_now",
state=self.dp2(self.carbon_intensity.get(0, 0)),
attributes={
"friendly_name": "Grid carbon intensity now",
"state_class": "measurement",
"unit_of_measurement": "g/kWh",
"icon": "mdi:molecule-co2",
},
)
if save and save == "best":
self.dashboard_item(
self.prefix + ".best_battery_hours_left",
state=self.dp2(hours_left),
attributes={"friendly_name": "Predicted Battery Hours left best", "state_class": "measurement", "unit_of_measurement": "hours", "icon": "mdi:timelapse"},
)
postfix = ""
for car_n in range(self.num_cars):
if car_n > 0:
postfix = "_" + str(car_n)
self.dashboard_item(
self.prefix + ".car_soc_best" + postfix,
state=self.dp2(final_car_soc[car_n] / self.car_charging_battery_size[car_n] * 100.0),
attributes={
"results": self.filtered_times(predict_car_soc_time[car_n]),
"today": self.filtered_today(predict_car_soc_time[car_n]),
"friendly_name": "Car " + str(car_n) + " battery SOC best",
"state_class": "measurement",
"unit_of_measurement": "%",
"icon": "mdi:battery",
},
)
self.dashboard_item(
self.prefix + ".soc_kw_best",
state=self.dp3(final_soc),
attributes={
"results": self.filtered_times(predict_soc_time),
"today": self.filtered_today(predict_soc_time),
"friendly_name": "Battery SOC kWh best",
"state_class": "measurement",
"unit_of_measurement": "kWh",
"first_charge_kwh": first_charge_soc,
"icon": "mdi:battery",
},
)
self.dashboard_item(
self.prefix + ".battery_power_best",
state=self.dp3(final_soc),
attributes={
"results": self.filtered_times(predict_battery_power),
"today": self.filtered_today(predict_battery_power),
"friendly_name": "Predicted Battery Power Best",
"state_class": "measurement",
"unit_of_measurement": "kW",
"icon": "mdi:battery",
},
)
self.dashboard_item(
self.prefix + ".battery_cycle_best",
state=self.dp3(final_battery_cycle),
attributes={
"results": self.filtered_times(predict_battery_cycle),
"today": self.filtered_today(predict_battery_cycle),
"friendly_name": "Predicted Battery Cycle Best",
"state_class": "measurement",
"unit_of_measurement": "kWh",
"icon": "mdi:battery",
},
)
self.dashboard_item(
self.prefix + ".pv_power_best",
state=self.dp3(0),
attributes={
"results": self.filtered_times(predict_pv_power),
"today": self.filtered_today(predict_pv_power),
"friendly_name": "Predicted PV Power Best",
"state_class": "measurement",
"unit_of_measurement": "kW",
"icon": "mdi:battery",
},
)
self.dashboard_item(
self.prefix + ".grid_power_best",
state=self.dp3(0),
attributes={
"results": self.filtered_times(predict_grid_power),
"today": self.filtered_today(predict_grid_power),
"friendly_name": "Predicted Grid Power Best",
"state_class": "measurement",
"unit_of_measurement": "kW",
"icon": "mdi:battery",
},
)
self.dashboard_item(
self.prefix + ".load_power_best",
state=self.dp3(final_soc),
attributes={
"results": self.filtered_times(predict_load_power),
"today": self.filtered_today(predict_load_power),
"friendly_name": "Predicted Load Power Best",
"state_class": "measurement",
"unit_of_measurement": "kW",
"icon": "mdi:battery",
},
)
self.dashboard_item(
self.prefix + ".soc_kw_best_h1",
state=self.dp3(self.predict_soc[60]),
attributes={"friendly_name": "Predicted SOC kWh best + 1h", "state_class": "measurement", "unit_of_measurement": "kWh", "icon": "mdi:battery"},
)
self.dashboard_item(
self.prefix + ".soc_kw_best_h8",
state=self.dp3(self.predict_soc[60 * 8]),
attributes={"friendly_name": "Predicted SOC kWh best + 8h", "state_class": "measurement", "unit_of_measurement": "kWh", "icon": "mdi:battery"},
)
self.dashboard_item(
self.prefix + ".soc_kw_best_h12",
state=self.dp3(self.predict_soc[60 * 12]),
attributes={"friendly_name": "Predicted SOC kWh best + 12h", "state_class": "measurement", "unit _of_measurement": "kWh", "icon": "mdi:battery"},
)
self.dashboard_item(
self.prefix + ".best_soc_min_kwh",
state=self.dp3(soc_min),
attributes={
"time": self.time_abs_str(soc_min_minute),
"friendly_name": "Predicted minimum SOC best",
"state_class": "measurement",
"unit_of_measurement": "kWh",
"icon": "mdi:battery-arrow-down-outline",
},
)
self.dashboard_item(
self.prefix + ".best_export_energy",
state=self.dp3(final_export_kwh),
attributes={
"results": self.filtered_times(export_kwh_time),
"today": self.filtered_today(export_kwh_time),
"export_until_charge_kwh": export_to_first_charge,
"friendly_name": "Predicted exports best",
"state_class": "measurement",
"unit_of_measurement": "kWh",
"icon": "mdi:transmission-tower-export",
},
)
self.dashboard_item(
self.prefix + ".best_load_energy",
state=self.dp3(final_load_kwh),
attributes={
"results": self.filtered_times(load_kwh_time),
"today": self.filtered_today(load_kwh_time),
"friendly_name": "Predicted load best",
"state_class": "measurement",
"unit_of_measurement": "kWh",
"icon": "mdi:home-lightning-bolt",
},
)
self.dashboard_item(
self.prefix + ".best_pv_energy",
state=self.dp3(final_pv_kwh),
attributes={
"results": self.filtered_times(pv_kwh_time),
"today": self.filtered_today(pv_kwh_time),
"friendly_name": "Predicted PV best",
"state_class": "measurement",
"unit_of_measurement": "kWh",
"icon": "mdi:solar-power",
},
)
self.dashboard_item(
self.prefix + ".best_import_energy",
state=self.dp3(final_import_kwh),
attributes={
"results": self.filtered_times(import_kwh_time),
"today": self.filtered_today(import_kwh_time),
"friendly_name": "Predicted imports best",
"state_class": "measurement",
"unit_of_measurement": "kWh",
"icon": "mdi:transmission-tower-import",
},
)
self.dashboard_item(
self.prefix + ".best_import_energy_battery",
state=self.dp3(final_import_kwh_battery),
attributes={
"friendly_name": "Predicted import to battery best",
"state_class": "measurement",
"unit_of_measurement": "kWh",
"icon": "mdi:transmission-tower-import",
},
)
self.dashboard_item(
self.prefix + ".best_import_energy_house",
state=self.dp3(final_import_kwh_house),
attributes={
"friendly_name": "Predicted import to house best",
"state_class": "measurement",
"unit_of_measurement": "kWh",
"icon": "mdi:transmission-tower-import",
},
)
self.dashboard_item(
self.prefix + ".best_metric",
state=self.dp2(final_metric),
attributes={
"results": self.filtered_times(metric_time),
"today": self.filtered_today(metric_time),
"friendly_name": "Predicted best metric (cost)",
"state_class": "measurement",
"unit_of_measurement": self.currency_symbols[1],
"icon": "mdi:currency-usd",
},
)
self.dashboard_item(
self.prefix + ".record", state=0.0, attributes={"results": self.filtered_times(record_time), "friendly_name": "Prediction window", "state_class": "measurement"}
)
self.dashboard_item(
self.prefix + ".iboost_best",
state=self.dp2(final_iboost_kwh),
attributes={
"results": self.filtered_times(predict_iboost),
"today": self.filtered_today(predict_iboost, resetmidnight=True),
"friendly_name": "Predicted iBoost energy best",
"state_class": "measurement",
"unit_of_measurement": "kWh",
"icon": "mdi:water-boiler",
},
)
self.dashboard_item(
"binary_sensor." + self.prefix + "_iboost_active",
state=self.iboost_running,
attributes={"friendly_name": "iBoost active", "icon": "mdi:water-boiler", "solar": self.iboost_running_solar, "full": self.iboost_running_full},
)
self.find_spare_energy(self.predict_soc, predict_export, step, first_charge)
if self.carbon_enable:
self.dashboard_item(
self.prefix + ".carbon_best",
state=self.dp2(final_carbon_g),
attributes={
"results": self.filtered_times(predict_carbon_g),
"today": self.filtered_today(predict_carbon_g),
"friendly_name": "Predicted Carbon energy best",
"state_class": "measurement",
"unit_of_measurement": "g",
"icon": "mdi:molecule-co2",
},
)
if save and save == "debug":
self.dashboard_item(
self.prefix + ".pv_power_debug",
state=self.dp3(final_soc),
attributes={
"results": self.filtered_times(predict_pv_power),
"today": self.filtered_today(predict_pv_power),
"friendly_name": "Predicted PV Power Debug",
"state_class": "measurement",
"unit_of_measurement": "kW",
"icon": "mdi:battery",
},
)
self.dashboard_item(
self.prefix + ".grid_power_debug",
state=self.dp3(final_soc),
attributes={
"results": self.filtered_times(predict_grid_power),
"today": self.filtered_today(predict_grid_power),
"friendly_name": "Predicted Grid Power Debug",
"state_class": "measurement",
"unit_of_measurement": "kW",
"icon": "mdi:battery",
},
)
self.dashboard_item(
self.prefix + ".load_power_debug",
state=self.dp3(final_soc),
attributes={
"results": self.filtered_times(predict_load_power),
"today": self.filtered_today(predict_load_power),
"friendly_name": "Predicted Load Power Debug",
"state_class": "measurement",
"unit_of_measurement": "kW",
"icon": "mdi:battery",
},
)
self.dashboard_item(
self.prefix + ".battery_power_debug",
state=self.dp3(final_soc),
attributes={
"results": self.filtered_times(predict_battery_power),
"today": self.filtered_today(predict_battery_power),
"friendly_name": "Predicted Battery Power Debug",
"state_class": "measurement",
"unit_of_measurement": "kW",
"icon": "mdi:battery",
},
)
if save and save == "best10":
self.dashboard_item(
self.prefix + ".soc_kw_best10",
state=self.dp3(final_soc),
attributes={
"results": self.filtered_times(predict_soc_time),
"today": self.filtered_today(predict_soc_time),
"friendly_name": "Battery SOC kWh best 10%",
"state_class": "measurement",
"unit_of_measurement": "kWh",
"first_charge_kwh": first_charge_soc,
"icon": "mdi:battery",
},
)
self.dashboard_item(
self.prefix + ".best10_pv_energy",
state=self.dp3(final_pv_kwh),
attributes={
"results": self.filtered_times(pv_kwh_time),
"today": self.filtered_today(pv_kwh_time),
"friendly_name": "Predicted PV best 10%",
"state_class": "measurement",
"unit_of_measurement": "kWh",
"icon": "mdi:solar-power",
},
)
self.dashboard_item(
self.prefix + ".best10_metric",
state=self.dp2(final_metric),
attributes={
"results": self.filtered_times(metric_time),
"today": self.filtered_today(metric_time),
"friendly_name": "Predicted best 10% metric (cost)",
"state_class": "measurement",
"unit_of_measurement": self.currency_symbols[1],
"icon": "mdi:currency-usd",
},
)
self.dashboard_item(
self.prefix + ".best10_export_energy",
state=self.dp3(final_export_kwh),
attributes={
"results": self.filtered_times(export_kwh_time),
"today": self.filtered_today(export_kwh_time),
"export_until_charge_kwh": export_to_first_charge,
"friendly_name": "Predicted exports best 10%",
"state_class": "measurement",
"unit_of_measurement": "kWh",
"icon": "mdi:transmission-tower-export",
},
)
self.dashboard_item(
self.prefix + ".best10_load_energy",
state=self.dp3(final_load_kwh),
attributes={"friendly_name": "Predicted load best 10%", "state_class": "measurement", "unit_of_measurement": "kWh", "icon": "mdi:home-lightning-bolt"},
)
self.dashboard_item(
self.prefix + ".best10_import_energy",
state=self.dp3(final_import_kwh),
attributes={
"results": self.filtered_times(import_kwh_time),
"today": self.filtered_today(import_kwh_time),
"friendly_name": "Predicted imports best 10%",
"state_class": "measurement",
"unit_of_measurement": "kWh",
"icon": "mdi:transmission-tower-import",
},
)
if save and save == "base10":
self.dashboard_item(
self.prefix + ".soc_kw_base10",
state=self.dp3(final_soc),
attributes={
"results": self.filtered_times(predict_soc_time),
"today": self.filtered_today(predict_soc_time),
"friendly_name": "Battery SOC kWh base 10%",
"state_class": "measurement",
"unit_of_measurement": "kWh",
"icon": "mdi:battery",
},
)
self.dashboard_item(
self.prefix + ".base10_pv_energy",
state=self.dp3(final_pv_kwh),
attributes={
"results": self.filtered_times(pv_kwh_time),
"today": self.filtered_today(pv_kwh_time),
"friendly_name": "Predicted PV base 10%",
"state_class": "measurement",
"unit_of_measurement": "kWh",
"icon": "mdi:solar-power",
},
)
self.dashboard_item(
self.prefix + ".base10_metric",
state=self.dp2(final_metric),
attributes={
"results": self.filtered_times(metric_time),
"today": self.filtered_today(metric_time),
"friendly_name": "Predicted base 10% metric (cost)",
"state_class": "measurement",
"unit_of_measurement": self.currency_symbols[1],
"icon": "mdi:currency-usd",
},
)
self.dashboard_item(
self.prefix + ".base10_export_energy",
state=self.dp3(final_export_kwh),
attributes={
"results": self.filtered_times(export_kwh_time),
"today": self.filtered_today(export_kwh_time),
"export_until_charge_kwh": export_to_first_charge,
"friendly_name": "Predicted exports base 10%",
"state_class": "measurement",
"unit_of_measurement": "kWh",
"icon": "mdi:transmission-tower-export",
},
)
self.dashboard_item(
self.prefix + ".base10_load_energy",
state=self.dp3(final_load_kwh),
attributes={"friendly_name": "Predicted load base 10%", "state_class": "measurement", "unit_of_measurement": "kWh", "icon": "mdi:home-lightning-bolt"},
)
self.dashboard_item(
self.prefix + ".base10_import_energy",
state=self.dp3(final_import_kwh),
attributes={
"results": self.filtered_times(import_kwh_time),
"today": self.filtered_today(import_kwh_time),
"friendly_name": "Predicted imports base 10%",
"state_class": "measurement",
"unit_of_measurement": "kWh",
"icon": "mdi:transmission-tower-import",
},
)
return (
final_metric,
import_kwh_battery,
import_kwh_house,
export_kwh,
soc_min,
final_soc,
soc_min_minute,
final_battery_cycle,
final_metric_keep,
final_iboost_kwh,
final_carbon_g,
)
def time_now_str(self):
"""
Return time now as human string
"""
return (self.midnight + timedelta(minutes=self.minutes_now)).strftime("%H:%M:%S")
def time_abs_str(self, minute):
"""
Return time absolute as human string
"""
return (self.midnight + timedelta(minutes=minute)).strftime("%m-%d %H:%M:%S")
def rate_replicate(self, rates, rate_io={}, is_import=True, is_gas=False):
"""
We don't get enough hours of data for Octopus, so lets assume it repeats until told others
"""
minute = -24 * 60
rate_last = 0
adjusted_rates = {}
replicated_rates = {}
# Add 48 extra hours to make sure the whole cycle repeats another day
while minute < (self.forecast_minutes + 48 * 60):
if minute not in rates:
adjust_type = "copy"
# Take 24-hours previous if missing rate
if (minute >= 24 * 60) and ((minute - 24 * 60) in rates):
minute_mod = minute - 24 * 60
else:
minute_mod = minute % (24 * 60)
if (minute_mod in rate_io) and rate_io[minute_mod]:
# Dont replicate Intelligent rates into the next day as it will be different
rate_offset = self.rate_max
elif minute_mod in rates:
rate_offset = rates[minute_mod]
else:
# Missing rate within 24 hours - fill with dummy last rate
rate_offset = rate_last
# Only offset once not every day
if minute_mod not in adjusted_rates:
if (
is_import
and self.get_arg("futurerate_adjust_import", False)
and (minute in self.future_energy_rates_import)
and (minute_mod in self.future_energy_rates_import)
):
prev_rate = rate_offset
rate_offset = rate_offset - self.future_energy_rates_import[minute_mod] + self.future_energy_rates_import[minute]
adjust_type = "future"
elif (
(not is_import)
and (not is_gas)
and self.get_arg("futurerate_adjust_export", False)
and (minute in self.future_energy_rates_export)
and (minute_mod in self.future_energy_rates_export)
):
rate_offset = max(rate_offset - self.future_energy_rates_export[minute_mod] + self.future_energy_rates_export[minute], 0)
adjust_type = "future"
elif is_import:
rate_offset = rate_offset + self.metric_future_rate_offset_import
if self.metric_future_rate_offset_import:
adjust_type = "offset"
elif (not is_import) and (not is_gas):
rate_offset = max(rate_offset + self.metric_future_rate_offset_export, 0)
if self.metric_future_rate_offset_export:
adjust_type = "offset"
adjusted_rates[minute] = True
rates[minute] = rate_offset
replicated_rates[minute] = adjust_type
else:
rate_last = rates[minute]
minute += 1
return rates, replicated_rates
def find_charge_window(self, rates, minute, threshold_rate, find_high):
"""
Find the charging windows based on the low rate threshold (percent below average)
"""
rate_low_start = -1
rate_low_end = -1
rate_low_average = 0
rate_low_rate = 0
rate_low_count = 0
stop_at = self.forecast_minutes + self.minutes_now + 12 * 60
# Scan for lower rate start and end
while minute < stop_at and not (minute >= (self.forecast_minutes + self.minutes_now) and (rate_low_start < 0)):
if minute in rates:
rate = rates[minute]
if (
((not find_high) and (rate <= threshold_rate))
or (find_high and (rate >= threshold_rate) and (rate > 0))
or minute in self.manual_all_times
or rate_low_start in self.manual_all_times
):
rate_diff = abs(rate - rate_low_rate)
if (rate_low_start >= 0) and rate_diff > self.combine_rate_threshold:
# Refuse mixed rates that are different by more than threshold
rate_low_end = minute
break
if find_high and (not self.combine_discharge_slots) and (rate_low_start >= 0) and ((minute - rate_low_start) >= self.discharge_slot_split):
# If combine is disabled, for export slots make them all N minutes so we can select some not all
rate_low_end = minute
break
if (not find_high) and (not self.combine_charge_slots) and (rate_low_start >= 0) and ((minute - rate_low_start) >= self.charge_slot_split):
# If combine is disabled, for import slots make them all N minutes so we can select some not all
rate_low_end = minute
break
if (rate_low_start in self.manual_all_times or minute in self.manual_all_times) and (rate_low_start >= 0) and ((minute - rate_low_start) >= 30):
# Manual slot
rate_low_end = minute
break
if find_high and (rate_low_start >= 0) and ((minute - rate_low_start) >= 60 * 4):
# Export slot can never be bigger than 4 hours
rate_low_end = minute
break
if rate_low_start < 0:
rate_low_start = minute
rate_low_end = stop_at
rate_low_count = 1
rate_low_average = rate
rate_low_rate = rate
elif rate_low_end > minute:
rate_low_average += rate
rate_low_count += 1
else:
if rate_low_start >= 0:
rate_low_end = minute
break
else:
if rate_low_start >= 0 and rate_low_end >= minute:
rate_low_end = minute
break
minute += 1
if rate_low_count:
rate_low_average = self.dp2(rate_low_average / rate_low_count)
return rate_low_start, rate_low_end, rate_low_average
def basic_rates(self, info, rtype, prev=None, rate_replicate={}):
"""
Work out the energy rates based on user supplied time periods
works on a 24-hour period only and then gets replicated later for future days
"""
rates = {}
if prev:
rates = prev.copy()
else:
# Set to zero
for minute in range(24 * 60):
rates[minute] = 0
max_minute = max(rates) + 1
midnight = datetime.strptime("00:00:00", "%H:%M:%S")
for this_rate in info:
if this_rate:
start_str = this_rate.get("start", "00:00:00")
start_str = self.resolve_arg("start", start_str, "00:00:00")
end_str = this_rate.get("end", "00:00:00")
end_str = self.resolve_arg("end", end_str, "00:00:00")
load_scaling = this_rate.get("load_scaling", None)
if start_str.count(":") < 2:
start_str += ":00"
if end_str.count(":") < 2:
end_str += ":00"
try:
start = datetime.strptime(start_str, "%H:%M:%S")
except ValueError:
self.log("Warn: Bad start time {} provided in energy rates".format(start_str))
self.record_status("Bad start time {} provided in energy rates".format(start_str), had_errors=True)
continue
try:
end = datetime.strptime(end_str, "%H:%M:%S")
except ValueError:
self.log("Warn: Bad end time {} provided in energy rates".format(end_str))
self.record_status("Bad end time {} provided in energy rates".format(end_str), had_errors=True)
continue
date = None
if "date" in this_rate:
date_str = self.resolve_arg("date", this_rate["date"])
try:
date = datetime.strptime(date_str, "%Y-%m-%d")
except ValueError:
self.log("Warn: Bad date {} provided in energy rates".format(this_rate["date"]))
self.record_status("Bad date {} provided in energy rates".format(this_rate["date"]), had_errors=True)
continue
# Support increment to existing rates (for override)
if "rate" in this_rate:
rate = this_rate.get("rate", 0.0)
rate_increment = False
elif "rate_increment" in this_rate:
rate = this_rate.get("rate_increment", 0.0)
rate_increment = True
else:
rate = 0
rate_increment = True
# Resolve any sensor links
rate = self.resolve_arg("rate", rate, 0.0)
# Ensure the end result is a float
try:
rate = float(rate)
except ValueError:
self.log("Warn: Bad rate {} provided in energy rates".format(rate))
self.record_status("Bad rate {} provided in energy rates".format(rate), had_errors=True)
continue
# Time in minutes
start_minutes = max(self.minutes_to_time(start, midnight), 0)
end_minutes = min(self.minutes_to_time(end, midnight), 24 * 60 - 1)
self.log(
"Adding rate {}: {} => {} to {} @ {} date {} increment {}".format(
rtype, this_rate, self.time_abs_str(start_minutes), self.time_abs_str(end_minutes), rate, date, rate_increment
)
)
# Make end > start
if end_minutes <= start_minutes:
end_minutes += 24 * 60
# Adjust for date if specified
if date:
delta_minutes = self.minutes_to_time(date, self.midnight)
start_minutes += delta_minutes
end_minutes += delta_minutes
# Store rates against range
if end_minutes >= (-24 * 60) and start_minutes < max_minute:
for minute in range(start_minutes, end_minutes):
minute_mod = minute % max_minute
if (not date) or (minute >= (-24 * 60) and minute < max_minute):
minute_index = minute_mod
# For incremental adjustments we have to loop over 24-hour periods
while minute_index < max_minute:
if rate_increment:
rates[minute_index] = rates.get(minute_index, 0.0) + rate
rate_replicate[minute_index] = "increment"
else:
rates[minute_index] = rate
rate_replicate[minute_index] = "user"
if load_scaling is not None:
self.load_scaling_dynamic[minute_index] = load_scaling
if date or not prev:
break
minute_index += 24 * 60
if not date and not prev:
rates[minute_mod + max_minute] = rate
if load_scaling is not None:
self.load_scaling_dynamic[minute_mod + max_minute] = load_scaling
return rates
def plan_iboost_smart(self):
"""
Smart iboost planning
"""
plan = []
iboost_today = self.iboost_today
iboost_max = self.iboost_max_energy
iboost_power = self.iboost_max_power * 60
low_rates = []
start_minute = int(self.minutes_now / 30) * 30
for minute in range(start_minute, start_minute + self.forecast_minutes, 30):
import_rate = self.rate_import.get(minute, self.rate_min)
export_rate = self.rate_export.get(minute, 0)
low_rates.append({"start": minute, "end": minute + 30, "average": import_rate, "export": export_rate})
# Get prices
if self.iboost_smart:
price_sorted = self.sort_window_by_price(low_rates, reverse_time=True)
price_sorted.reverse()
else:
price_sorted = [n for n in range(len(low_rates))]
total_days = int((self.forecast_minutes + self.minutes_now + 24 * 60 - 1) / (24 * 60))
iboost_soc = [0 for n in range(total_days)]
iboost_soc[0] = iboost_today
for window_n in price_sorted:
window = low_rates[window_n]
price = window["average"]
export_price = window["export"]
length = 0
kwh = 0
for day in range(0, total_days):
day_start_minutes = day * 24 * 60
day_end_minutes = day_start_minutes + 24 * 60
start = max(window["start"], self.minutes_now, day_start_minutes)
end = min(window["end"], day_end_minutes)
if start < end:
rate_okay = True
# Boost on import/export rate
if price > self.iboost_rate_threshold:
rate_okay = False
if export_price > self.iboost_rate_threshold_export:
rate_okay = False
# Boost on gas rate vs import price
if self.iboost_gas and self.rate_gas:
gas_rate = self.rate_gas.get(start, 99) * self.iboost_gas_scale
if price > gas_rate:
rate_okay = False
# Boost on gas rate vs export price
if self.iboost_gas_export and self.rate_gas:
gas_rate = self.rate_gas.get(start, 99) * self.iboost_gas_scale
if export_price > gas_rate:
rate_okay = False
if not rate_okay:
continue
# Work out charging amounts
length = end - start
hours = length / 60
kwh = iboost_power * hours
kwh = min(kwh, iboost_max - iboost_soc[day])
if kwh > 0:
new_slot = {}
new_slot["start"] = start
new_slot["end"] = end
new_slot["kwh"] = self.dp3(kwh)
new_slot["average"] = window["average"]
new_slot["cost"] = self.dp2(new_slot["average"] * kwh)
plan.append(new_slot)
iboost_soc[day] = self.dp3(iboost_soc[day] + kwh)
# Return sorted back in time order
plan = self.sort_window_by_time(plan)
return plan
def plan_car_charging(self, car_n, low_rates):
"""
Plan when the car will charge, taking into account ready time and pricing
"""
plan = []
car_soc = self.car_charging_soc[car_n]
max_price = self.car_charging_plan_max_price[car_n]
if self.car_charging_plan_smart[car_n]:
price_sorted = self.sort_window_by_price(low_rates, reverse_time=True)
price_sorted.reverse()
else:
price_sorted = [n for n in range(len(low_rates))]
try:
ready_time = datetime.strptime(self.car_charging_plan_time[car_n], "%H:%M:%S")
except ValueError:
ready_time = datetime.strptime("07:00:00", "%H:%M:%S")
self.log("Warn: Car charging plan time for car {} is invalid".format(car_n))
ready_minutes = ready_time.hour * 60 + ready_time.minute
# Ready minutes wrap?
if ready_minutes < self.minutes_now:
ready_minutes += 24 * 60
# Car charging now override
extra_slot = {}
if self.car_charging_now[car_n]:
start = int(self.minutes_now / 30) * 30
end = start + 30
extra_slot["start"] = start
extra_slot["end"] = end
extra_slot["average"] = self.rate_import.get(start, self.rate_min)
self.log("Car is charging now slot {}".format(extra_slot))
for window_p in price_sorted:
window = low_rates[window_p]
if window["start"] == start:
price_sorted.remove(window_p)
self.log("Remove old window {}".format(window_p))
break
price_sorted = [-1] + price_sorted
for window_n in price_sorted:
if window_n == -1:
window = extra_slot
else:
window = low_rates[window_n]
start = max(window["start"], self.minutes_now)
end = min(window["end"], ready_minutes)
price = window["average"]
length = 0
kwh = 0
# Stop once we have enough charge, allow small margin for rounding
if (car_soc + 0.1) >= self.car_charging_limit[car_n]:
break
# Skip past windows
if end <= start:
continue
# Skip over prices when they are too high
if (max_price != 0) and price > max_price:
continue
# Compute amount of charge
length = end - start
hours = length / 60
kwh = self.car_charging_rate[car_n] * hours
kwh_add = kwh * self.car_charging_loss
kwh_left = max(self.car_charging_limit[car_n] - car_soc, 0)
# Clamp length to required amount (shorten the window)
if kwh_add > kwh_left:
percent = kwh_left / kwh_add
length = min(round(((length * percent) / 5) + 0.5, 0) * 5, end - start)
end = start + length
hours = length / 60
kwh = self.car_charging_rate[car_n] * hours
kwh_add = min(kwh * self.car_charging_loss, kwh_left)
kwh = kwh_add / self.car_charging_loss
# Work out charging amounts
if kwh > 0:
car_soc = self.dp3(car_soc + kwh_add)
new_slot = {}
new_slot["start"] = start
new_slot["end"] = end
new_slot["kwh"] = self.dp3(kwh)
new_slot["average"] = window["average"]
new_slot["cost"] = self.dp2(new_slot["average"] * kwh)
plan.append(new_slot)
# Return sorted back in time order
plan = self.sort_window_by_time(plan)
return plan
def add_now_to_octopus_slot(self, octopus_slots, now_utc):
"""
For intelligent charging, add in if the car is charging now as a low rate slot (workaround for Ohme)
"""
for car_n in range(self.num_cars):
if self.car_charging_now[car_n]:
minutes_start_slot = int(self.minutes_now / 30) * 30
minutes_end_slot = minutes_start_slot + 30
slot_start_date = self.midnight_utc + timedelta(minutes=minutes_start_slot)
slot_end_date = self.midnight_utc + timedelta(minutes=minutes_end_slot)
slot = {}
slot["start"] = slot_start_date.strftime(TIME_FORMAT)
slot["end"] = slot_end_date.strftime(TIME_FORMAT)
octopus_slots.append(slot)
self.log("Car is charging now - added new IO slot {}".format(slot))
return octopus_slots
def load_saving_slot(self, octopus_saving_slots, export=False, rate_replicate={}):
"""
Load octopus saving session slot
"""
start_minutes = 0
end_minutes = 0
for octopus_saving_slot in octopus_saving_slots:
start = octopus_saving_slot["start"]
end = octopus_saving_slot["end"]
rate = octopus_saving_slot["rate"]
state = octopus_saving_slot["state"]
if start and end:
try:
start = self.str2time(start)
end = self.str2time(end)
except ValueError:
start = None
end = None
self.log("Warn: Unable to decode Octopus saving session start/end time")
if state and (not start or not end):
self.log("Currently in saving session, assume current 30 minute slot")
start_minutes = int(self.minutes_now / 30) * 30
end_minutes = start_minutes + 30
elif start and end:
start_minutes = self.minutes_to_time(start, self.midnight_utc)
end_minutes = min(self.minutes_to_time(end, self.midnight_utc), self.forecast_minutes)
if start_minutes >= 0 and end_minutes != start_minutes and start_minutes < self.forecast_minutes:
self.log("Setting Octopus saving session in range {} - {} export {} rate {}".format(self.time_abs_str(start_minutes), self.time_abs_str(end_minutes), export, rate))
for minute in range(start_minutes, end_minutes):
if export:
self.rate_export[minute] += rate
else:
self.rate_import[minute] += rate
self.load_scaling_dynamic[minute] = self.load_scaling_saving
rate_replicate[minute] = "saving"
def decode_octopus_slot(self, slot, raw=False):
"""
Decode IOG slot
"""
if "start" in slot:
start = datetime.strptime(slot["start"], TIME_FORMAT)
end = datetime.strptime(slot["end"], TIME_FORMAT)
else:
start = datetime.strptime(slot["startDtUtc"], TIME_FORMAT_OCTOPUS)
end = datetime.strptime(slot["endDtUtc"], TIME_FORMAT_OCTOPUS)
source = slot.get("source", "")
location = slot.get("location", "")
start_minutes = self.minutes_to_time(start, self.midnight_utc)
end_minutes = self.minutes_to_time(end, self.midnight_utc)
org_minutes = end_minutes - start_minutes
# Cap slot times into the forecast itself
if not raw:
start_minutes = max(start_minutes, 0)
end_minutes = max(min(end_minutes, self.forecast_minutes + self.minutes_now), start_minutes)
if start_minutes == end_minutes:
return 0, 0, 0, source, location
cap_minutes = end_minutes - start_minutes
cap_hours = cap_minutes / 60
# The load expected is stored in chargeKwh for the period in use
if "charge_in_kwh" in slot:
kwh = abs(float(slot.get("charge_in_kwh", 0.0)))
else:
kwh = abs(float(slot.get("chargeKwh", 0.0)))
if not kwh:
kwh = self.car_charging_rate[0] * cap_hours
else:
kwh = kwh * cap_minutes / org_minutes
return start_minutes, end_minutes, kwh, source, location
def load_octopus_slots(self, octopus_slots):
"""
Turn octopus slots into charging plan
"""
new_slots = []
for slot in octopus_slots:
start_minutes, end_minutes, kwh, source, location = self.decode_octopus_slot(slot)
if (end_minutes > start_minutes) and (end_minutes > self.minutes_now) and (not location or location == "AT_HOME"):
new_slot = {}
new_slot["start"] = start_minutes
new_slot["end"] = end_minutes
new_slot["kwh"] = kwh
if source != "bump-charge":
new_slot["average"] = self.rate_min # Assume price in min
else:
new_slot["average"] = self.rate_max # Assume price is max
new_slot["cost"] = new_slot["average"] * kwh
new_slots.append(new_slot)
return new_slots
def rate_scan_export(self, rates, print=True):
"""
Scan the rates and work out min/max
"""
self.rate_export_min, self.rate_export_max, self.rate_export_average, self.rate_export_min_minute, self.rate_export_max_minute = self.rate_minmax(rates)
if print:
self.log("Export rates min {} max {} average {}".format(self.rate_export_min, self.rate_export_max, self.rate_export_average))
return rates
def publish_car_plan(self):
"""
Publish the car charging plan
"""
plan = []
postfix = ""
for car_n in range(self.num_cars):
if car_n > 0:
postfix = "_" + str(car_n)
if not self.car_charging_slots[car_n]:
self.dashboard_item(
"binary_sensor." + self.prefix + "_car_charging_slot" + postfix,
state="off",
attributes={"planned": plan, "cost": None, "kWh": None, "friendly_name": "Predbat car charging slot" + postfix, "icon": "mdi:home-lightning-bolt-outline"},
)
self.dashboard_item(
self.prefix + ".car_charging_start" + postfix,
state="",
attributes={
"friendly_name": "Predbat car charge start time car" + postfix,
"timestamp": None,
"minutes_to": self.forecast_minutes,
"state_class": None,
"unit_of_measurement": None,
"device_class": "timestamp",
"icon": "mdi:table-clock",
},
)
else:
window = self.car_charging_slots[car_n][0]
if self.minutes_now >= window["start"] and self.minutes_now < window["end"]:
slot = True
else:
slot = False
time_format_time = "%H:%M:%S"
car_startt = self.midnight_utc + timedelta(minutes=window["start"])
car_start_time_str = car_startt.strftime(time_format_time)
minutes_to = max(window["start"] - self.minutes_now, 0)
self.dashboard_item(
self.prefix + ".car_charging_start" + postfix,
state=car_start_time_str,
attributes={
"friendly_name": "Predbat car charge start time car" + postfix,
"timestamp": car_startt.strftime(TIME_FORMAT),
"minutes_to": minutes_to,
"state_class": None,
"unit_of_measurement": None,
"device_class": "timestamp",
"icon": "mdi:table-clock",
},
)
total_kwh = 0
total_cost = 0
for window in self.car_charging_slots[car_n]:
start = self.time_abs_str(window["start"])
end = self.time_abs_str(window["end"])
kwh = self.dp2(window["kwh"])
average = self.dp2(window["average"])
cost = self.dp2(window["cost"])
show = {}
show["start"] = start
show["end"] = end
show["kwh"] = kwh
show["average"] = average
show["cost"] = cost
total_cost += cost
total_kwh += kwh
plan.append(show)
self.dashboard_item(
"binary_sensor." + self.prefix + "_car_charging_slot" + postfix,
state="on" if slot else "off",
attributes={
"planned": plan,
"cost": self.dp2(total_cost),
"kwh": self.dp2(total_kwh),
"friendly_name": "Predbat car charging slot" + postfix,
"icon": "mdi:home-lightning-bolt-outline",
},
)
def publish_rates_export(self):
"""
Publish the export rates
"""
window_str = ""
if self.high_export_rates:
window_n = 0
for window in self.high_export_rates:
rate_high_start = window["start"]
rate_high_end = window["end"]
rate_high_average = window["average"]
rate_high_minutes_to_start = max(rate_high_start - self.minutes_now, 0)
rate_high_minutes_to_end = max(rate_high_end - self.minutes_now, 0)
if window_str:
window_str += ", "
window_str += "{}: {} - {} @ {}".format(window_n, self.time_abs_str(rate_high_start), self.time_abs_str(rate_high_end), rate_high_average)
rate_high_start_date = self.midnight_utc + timedelta(minutes=rate_high_start)
rate_high_end_date = self.midnight_utc + timedelta(minutes=rate_high_end)
time_format_time = "%H:%M:%S"
if window_n == 0:
self.dashboard_item(
self.prefix + ".high_rate_export_start",
state=rate_high_start_date.strftime(time_format_time),
attributes={
"date": rate_high_start_date.strftime(TIME_FORMAT),
"friendly_name": "Next high export rate start",
"state_class": "timestamp",
"icon": "mdi:table-clock",
"minutes_to": rate_high_minutes_to_start,
"rate": self.dp2(rate_high_average),
},
)
self.dashboard_item(
self.prefix + ".high_rate_export_end",
state=rate_high_end_date.strftime(time_format_time),
attributes={
"date": rate_high_end_date.strftime(TIME_FORMAT),
"friendly_name": "Next high export rate end",
"state_class": "timestamp",
"icon": "mdi:table-clock",
"minutes_to": rate_high_minutes_to_end,
"rate": self.dp2(rate_high_average),
},
)
self.dashboard_item(
self.prefix + ".high_rate_export_cost",
state=self.dp2(rate_high_average),
attributes={
"friendly_name": "Next high export rate cost",
"state_class": "measurement",
"unit_of_measurement": self.currency_symbols[1],
"icon": "mdi:currency-usd",
},
)
in_high_rate = self.minutes_now >= rate_high_start and self.minutes_now <= rate_high_end
self.dashboard_item(
"binary_sensor." + self.prefix + "_high_rate_export_slot",
state="on" if in_high_rate else "off",
attributes={"friendly_name": "Predbat high rate slot", "icon": "mdi:home-lightning-bolt-outline"},
)
high_rate_minutes = (rate_high_end - self.minutes_now) if in_high_rate else (rate_high_end - rate_high_start)
self.dashboard_item(
self.prefix + ".high_rate_export_duration",
state=high_rate_minutes,
attributes={"friendly_name": "Next high export rate duration", "state_class": "measurement", "unit_of_measurement": "minutes", "icon": "mdi:table-clock"},
)
if window_n == 1:
self.dashboard_item(
self.prefix + ".high_rate_export_start_2",
state=rate_high_start_date.strftime(time_format_time),
attributes={
"date": rate_high_start_date.strftime(TIME_FORMAT),
"friendly_name": "Next+1 high export rate start",
"state_class": "timestamp",
"icon": "mdi:table-clock",
"rate": self.dp2(rate_high_average),
"minutes_to": rate_high_minutes_to_start,
},
)
self.dashboard_item(
self.prefix + ".high_rate_export_end_2",
state=rate_high_end_date.strftime(time_format_time),
attributes={
"date": rate_high_end_date.strftime(TIME_FORMAT),
"friendly_name": "Next+1 high export rate end",
"state_class": "timestamp",
"icon": "mdi:table-clock",
"rate": self.dp2(rate_high_average),
"minutes_to": rate_high_minutes_to_end,
},
)
self.dashboard_item(
self.prefix + ".high_rate_export_cost_2",
state=self.dp2(rate_high_average),
attributes={
"friendly_name": "Next+1 high export rate cost",
"state_class": "measurement",
"unit_of_measurement": self.currency_symbols[1],
"icon": "mdi:currency-usd",
},
)
window_n += 1
if window_str:
self.log("High export rate windows [{}]".format(window_str))
# Clear rates that aren't available
if not self.high_export_rates:
self.log("No high rate period found")
self.dashboard_item(
self.prefix + ".high_rate_export_start",
state="undefined",
attributes={
"date": None,
"friendly_name": "Next high export rate start",
"device_class": "timestamp",
"icon": "mdi:table-clock",
"minutes_to": self.forecast_minutes,
"rate": None,
},
)
self.dashboard_item(
self.prefix + ".high_rate_export_end",
state="undefined",
attributes={
"date": None,
"friendly_name": "Next high export rate end",
"device_class": "timestamp",
"icon": "mdi:table-clock",
"minutes_to": self.forecast_minutes,
"rate": None,
},
)
self.dashboard_item(
self.prefix + ".high_rate_export_cost",
state=self.dp2(self.rate_export_average),
attributes={
"friendly_name": "Next high export rate cost",
"state_class": "measurement",
"unit_of_measurement": self.currency_symbols[1],
"icon": "mdi:currency-usd",
},
)
self.dashboard_item(
"binary_sensor." + self.prefix + "_high_rate_export_slot",
state="off",
attributes={"friendly_name": "Predbat high export rate slot", "icon": "mdi:home-lightning-bolt-outline"},
)
self.dashboard_item(
self.prefix + ".high_rate_export_duration",
state=0,
attributes={"friendly_name": "Next high export rate duration", "state_class": "measurement", "unit_of_measurement": "minutes", "icon": "mdi:table-clock"},
)
if len(self.high_export_rates) < 2:
self.dashboard_item(
self.prefix + ".high_rate_export_start_2",
state="undefined",
attributes={
"date": None,
"friendly_name": "Next+1 high export rate start",
"device_class": "timestamp",
"icon": "mdi:table-clock",
"minutes_to": self.forecast_minutes,
"rate": None,
},
)
self.dashboard_item(
self.prefix + ".high_rate_export_end_2",
state="undefined",
attributes={
"date": None,
"friendly_name": "Next+1 high export rate end",
"device_class": "timestamp",
"icon": "mdi:table-clock",
"minutes_to": self.forecast_minutes,
"rate": None,
},
)
self.dashboard_item(
self.prefix + ".high_rate_export_cost_2",
state=self.dp2(self.rate_export_average),
attributes={
"friendly_name": "Next+1 high export rate cost",
"state_class": "measurement",
"unit_of_measurement": self.currency_symbols[1],
"icon": "mdi:currency-usd",
},
)
def rate_minmax(self, rates):
"""
Work out min and max rates
"""
rate_min = 99999
rate_min_minute = 0
rate_max_minute = 0
rate_max = 0
rate_average = 0
rate_n = 0
rate = 0
# Scan rates and find min/max/average
if rates:
rate = rates.get(self.minutes_now, 0)
for minute in range(self.minutes_now, self.forecast_minutes + self.minutes_now):
if minute in rates:
rate = rates[minute]
if rate > rate_max:
rate_max = rate
rate_max_minute = minute
if rate < rate_min:
rate_min = rate
rate_min_minute = minute
rate_average += rate
rate_n += 1
minute += 1
if rate_n:
rate_average /= rate_n
return self.dp2(rate_min), self.dp2(rate_max), self.dp2(rate_average), rate_min_minute, rate_max_minute
def rate_min_forward_calc(self, rates):
"""
Work out lowest rate from time forwards
"""
rate_array = []
rate_min_forward = {}
rate = self.rate_min
for minute in range(self.forecast_minutes + self.minutes_now + 48 * 60):
if minute in rates:
rate = rates[minute]
rate_array.append(rate)
# Work out the min rate going forward
for minute in range(self.minutes_now, self.forecast_minutes + 24 * 60 + self.minutes_now):
rate_min_forward[minute] = min(rate_array[minute:])
self.log("Rate min forward looking: now {} at end of forecast {}".format(self.dp2(rate_min_forward[self.minutes_now]), self.dp2(rate_min_forward[self.forecast_minutes])))
return rate_min_forward
def rate_scan_window(self, rates, rate_low_min_window, threshold_rate, find_high, return_raw=False):
"""
Scan for the next high/low rate window
"""
minute = 0
found_rates = []
lowest = 99
highest = -99
upcoming_period = self.minutes_now + 4 * 60
while True:
rate_low_start, rate_low_end, rate_low_average = self.find_charge_window(rates, minute, threshold_rate, find_high)
window = {}
window["start"] = rate_low_start
window["end"] = rate_low_end
window["average"] = rate_low_average
if rate_low_start >= 0:
if (return_raw or (rate_low_end > self.minutes_now)) and (rate_low_end - rate_low_start) >= rate_low_min_window:
if rate_low_average < lowest:
lowest = rate_low_average
if rate_low_average > highest:
highest = rate_low_average
found_rates.append(window)
minute = rate_low_end
else:
break
return found_rates, lowest, highest
def set_rate_thresholds(self):
"""
Set the high and low rate thresholds
"""
if self.rate_low_threshold > 0:
self.rate_import_cost_threshold = self.dp2(self.rate_average * self.rate_low_threshold)
else:
# In automatic mode select the only rate or everything but the most expensive
if (self.rate_max == self.rate_min) or (self.rate_export_max > self.rate_max):
self.rate_import_cost_threshold = self.rate_max
else:
self.rate_import_cost_threshold = self.rate_max - 0.5
# Compute the export rate threshold
if self.rate_high_threshold > 0:
self.rate_export_cost_threshold = self.dp2(self.rate_export_average * self.rate_high_threshold)
else:
# In automatic mode select the only rate or everything but the most cheapest
if (self.rate_export_max == self.rate_export_min) or (self.rate_export_min > self.rate_min):
self.rate_export_cost_threshold = self.rate_export_min
else:
self.rate_export_cost_threshold = self.rate_export_min + 0.5
# Rule out exports if the import rate is already higher unless it's a variable export tariff
if self.rate_export_max == self.rate_export_min:
self.rate_export_cost_threshold = max(self.rate_export_cost_threshold, self.dp2(self.rate_min))
self.log(
"Rate thresholds (for charge/discharge) are import {}p ({}) export {}p ({})".format(
self.rate_import_cost_threshold, self.rate_low_threshold, self.rate_export_cost_threshold, self.rate_high_threshold
)
)
def rate_add_io_slots(self, rates, octopus_slots):
"""
# Add in any planned octopus slots
"""
if octopus_slots:
# Add in IO slots
for slot in octopus_slots:
start_minutes, end_minutes, kwh, source, location = self.decode_octopus_slot(slot, raw=True)
# Ignore bump-charge slots as their cost won't change
if source != "bump-charge" and (not location or location == "AT_HOME"):
# Round slots to 30 minute boundary
start_minutes = int(round(start_minutes / 30, 0) * 30)
end_minutes = int(round(end_minutes / 30, 0) * 30)
self.log(
"Octopus Intelligent slot at {}-{} assumed price {} amount {} kWh location {} source {}".format(
self.time_abs_str(start_minutes), self.time_abs_str(end_minutes), self.rate_min, kwh, location, source
)
)
for minute in range(start_minutes, end_minutes):
if minute >= (-24 * 60) and minute < self.forecast_minutes:
rates[minute] = self.rate_min
return rates
def rate_scan(self, rates, print=True):
"""
Scan the rates and work out min/max
"""
self.low_rates = []
self.rate_min, self.rate_max, self.rate_average, self.rate_min_minute, self.rate_max_minute = self.rate_minmax(rates)
if print:
# Calculate minimum forward rates only once rate replicate has run (when print is True)
self.rate_min_forward = self.rate_min_forward_calc(self.rate_import)
self.log("Import rates min {} max {} average {}".format(self.rate_min, self.rate_max, self.rate_average))
return rates
def rate_scan_gas(self, rates, print=True):
"""
Scan the gas rates and work out min/max
"""
self.rate_gas_min, self.rate_gas_max, self.rate_gas_average, self.rate_gas_min_minute, self.rate_gas_max_minute = self.rate_minmax(rates)
if print:
self.log("Gas rates min {} max {} average {}".format(self.rate_gas_min, self.rate_gas_max, self.rate_gas_average))
return rates
def publish_rates_import(self):
"""
Publish the import rates
"""
window_str = ""
# Output rate info
if self.low_rates:
window_n = 0
for window in self.low_rates:
rate_low_start = window["start"]
rate_low_end = window["end"]
rate_low_average = window["average"]
rate_low_minutes_to_start = max(rate_low_start - self.minutes_now, 0)
rate_low_minutes_to_end = max(rate_low_end - self.minutes_now, 0)
if window_str:
window_str += ", "
window_str += "{}: {} - {} @ {}".format(window_n, self.time_abs_str(rate_low_start), self.time_abs_str(rate_low_end), rate_low_average)
rate_low_start_date = self.midnight_utc + timedelta(minutes=rate_low_start)
rate_low_end_date = self.midnight_utc + timedelta(minutes=rate_low_end)
time_format_time = "%H:%M:%S"
if window_n == 0:
self.dashboard_item(
self.prefix + ".low_rate_start",
state=rate_low_start_date.strftime(time_format_time),
attributes={
"date": rate_low_start_date.strftime(TIME_FORMAT),
"friendly_name": "Next low rate start",
"minutes_to": rate_low_minutes_to_start,
"device_class": "timestamp",
"state_class": None,
"rate": self.dp2(rate_low_average),
"icon": "mdi:table-clock",
},
)
self.dashboard_item(
self.prefix + ".low_rate_end",
state=rate_low_end_date.strftime(time_format_time),
attributes={
"date": rate_low_end_date.strftime(TIME_FORMAT),
"minutes_to": rate_low_minutes_to_end,
"friendly_name": "Next low rate end",
"device_class": "timestamp",
"state_class": None,
"rate": self.dp2(rate_low_average),
"icon": "mdi:table-clock",
},
)
self.dashboard_item(
self.prefix + ".low_rate_cost",
state=self.dp2(rate_low_average),
attributes={
"friendly_name": "Next low rate cost",
"state_class": "measurement",
"unit_of_measurement": self.currency_symbols[1],
"icon": "mdi:currency-usd",
},
)
in_low_rate = self.minutes_now >= rate_low_start and self.minutes_now <= rate_low_end
self.dashboard_item(
"binary_sensor." + self.prefix + "_low_rate_slot",
state="on" if in_low_rate else "off",
attributes={"friendly_name": "Predbat low rate slot", "icon": "mdi:home-lightning-bolt-outline"},
)
low_rate_minutes = (rate_low_end - self.minutes_now) if in_low_rate else (rate_low_end - rate_low_start)
self.dashboard_item(
self.prefix + ".low_rate_duration",
state=low_rate_minutes,
attributes={"friendly_name": "Next low rate duration", "state_class": "measurement", "unit_of_measurement": "minutes", "icon": "mdi:table-clock"},
)
if window_n == 1:
self.dashboard_item(
self.prefix + ".low_rate_start_2",
state=rate_low_start_date.strftime(time_format_time),
attributes={
"date": rate_low_start_date.strftime(TIME_FORMAT),
"friendly_name": "Next+1 low rate start",
"device_class": "timestamp",
"state_class": None,
"rate": self.dp2(rate_low_average),
"minutes_to": rate_low_minutes_to_start,
"icon": "mdi:table-clock",
},
)
self.dashboard_item(
self.prefix + ".low_rate_end_2",
state=rate_low_end_date.strftime(time_format_time),
attributes={
"date": rate_low_end_date.strftime(TIME_FORMAT),
"friendly_name": "Next+1 low rate end",
"device_class": "timestamp",
"state_class": None,
"rate": self.dp2(rate_low_average),
"minutes_to": rate_low_minutes_to_end,
"icon": "mdi:table-clock",
},
)
self.dashboard_item(
self.prefix + ".low_rate_cost_2",
state=rate_low_average,
attributes={
"friendly_name": "Next+1 low rate cost",
"state_class": "measurement",
"unit_of_measurement": self.currency_symbols[1],
"icon": "mdi:currency-usd",
},
)
window_n += 1
self.log("Low import rate windows [{}]".format(window_str))
# Clear rates that aren't available
if not self.low_rates:
self.log("No low rate period found")
self.dashboard_item(
self.prefix + ".low_rate_start",
state="undefined",
attributes={
"date": None,
"friendly_name": "Next low rate start",
"device_class": "timestamp",
"state_class": None,
"minutes_to": self.forecast_minutes,
"rate": None,
"icon": "mdi:table-clock",
},
)
self.dashboard_item(
self.prefix + ".low_rate_end",
state="undefined",
attributes={
"date": None,
"friendly_name": "Next low rate end",
"device_class": "timestamp",
"state_class": None,
"minutes_to": self.forecast_minutes,
"rate": None,
"icon": "mdi:table-clock",
},
)
self.dashboard_item(
self.prefix + ".low_rate_cost",
state=self.rate_average,
attributes={"friendly_name": "Next low rate cost", "state_class": "measurement", "unit_of_measurement": self.currency_symbols[1], "icon": "mdi:currency-usd"},
)
self.dashboard_item(
self.prefix + ".low_rate_duration",
state=0,
attributes={"friendly_name": "Next low rate duration", "state_class": "measurement", "unit_of_measurement": "minutes", "icon": "mdi:table-clock"},
)
self.dashboard_item(
"binary_sensor." + self.prefix + "_low_rate_slot", state="off", attributes={"friendly_name": "Predbat low rate slot", "icon": "mdi:home-lightning-bolt-outline"}
)
if len(self.low_rates) < 2:
self.dashboard_item(
self.prefix + ".low_rate_start_2",
state="undefined",
attributes={
"date": None,
"friendly_name": "Next+1 low rate start",
"device_class": "timestamp",
"state_class": None,
"minutes_to": self.forecast_minutes,
"rate": None,
"icon": "mdi:table-clock",
},
)
self.dashboard_item(
self.prefix + ".low_rate_end_2",
state="undefined",
attributes={
"date": None,
"friendly_name": "Next+1 low rate end",
"device_class": "timestamp",
"state_class": None,
"minutes_to": self.forecast_minutes,
"rate": None,
"icon": "mdi:table-clock",
},
)
self.dashboard_item(
self.prefix + ".low_rate_cost_2",
state=self.rate_average,
attributes={"friendly_name": "Next+1 low rate cost", "state_class": "measurement", "unit_of_measurement": self.currency_symbols[1], "icon": "mdi:currency-usd"},
)
def adjust_symbol(self, adjust_type):
"""
Returns an HTML symbol based on the adjust rate type.
Parameters:
- adjust_type (str): The type of adjustment.
Returns:
- symbol (str): The symbol corresponding to the adjust_type.
"""
symbol = ""
if adjust_type:
if adjust_type == "offset":
symbol = "? ⅆ"
elif adjust_type == "future":
symbol = "? ⚖"
elif adjust_type == "user":
symbol = "="
elif adjust_type == "increment":
symbol = "±"
elif adjust_type == "saving":
symbol = "$"
else:
symbol = "?"
return symbol
def car_charge_slot_kwh(self, minute_start, minute_end):
"""
Work out car charging amount in KWh for given 30-minute slot
"""
car_charging_kwh = 0.0
if self.num_cars > 0:
for car_n in range(self.num_cars):
for window in self.car_charging_slots[car_n]:
start = window["start"]
end = window["end"]
kwh = self.dp2(window["kwh"]) / (end - start)
for minute_offset in range(minute_start, minute_end, PREDICT_STEP):
if minute_offset >= start and minute_offset < end:
car_charging_kwh += kwh * PREDICT_STEP
car_charging_kwh = self.dp2(car_charging_kwh)
return car_charging_kwh
def hit_car_window(self, window_start, window_end):
"""
Does this window intersect a car charging window?
"""
if self.num_cars > 0:
for car_n in range(self.num_cars):
for window in self.car_charging_slots[car_n]:
start = window["start"]
end = window["end"]
if end > window_start and start < window_end:
return True
return False
def get_html_plan_header(self, plan_debug):
"""
Returns the header row for the HTML plan.
"""
html = ""
html += "
"
html += "| Time | "
if plan_debug:
html += "Import {} (w/loss) | ".format(self.currency_symbols[1])
html += "Export {} (w/loss) | ".format(self.currency_symbols[1])
else:
html += "Import {} | ".format(self.currency_symbols[1])
html += "Export {} | ".format(self.currency_symbols[1])
html += "State | | " # state can potentially be two cells for charging and discharging in the same slot
html += "Limit % | "
if plan_debug:
html += "PV kWh (10%) | "
html += "Load kWh (10%) | "
else:
html += "PV kWh | "
html += "Load kWh | "
if self.num_cars > 0:
html += "Car kWh | "
if self.iboost_enable:
html += "iBoost kWh | "
html += "SOC % | "
html += "Cost | "
html += "Total | "
if self.carbon_enable:
html += "CO2 g/kWh | "
html += "CO2 kg | "
html += "
"
return html
def publish_html_plan(self, pv_forecast_minute_step, pv_forecast_minute_step10, load_minutes_step, load_minutes_step10, end_record):
"""
Publish the current plan in HTML format
"""
plan_debug = self.get_arg("plan_debug")
html = ""
html += ""
html += "| Plan starts: {} last updated: {} version: {} | ".format(
self.now_utc.strftime("%Y-%m-%d %H:%M"), self.now_utc_real.strftime("%H:%M:%S"), THIS_VERSION
)
config_str = f"best_soc_min {self.best_soc_min} best_soc_max {self.best_soc_max} best_soc_keep {self.best_soc_keep} carbon_metric {self.carbon_metric} metric_self_sufficiency {self.metric_self_sufficiency} metric_battery_value_scaling {self.metric_battery_value_scaling}"
html += "
"
html += "| {} | ".format(config_str)
html += "
"
html += self.get_html_plan_header(plan_debug)
minute_now_align = int(self.minutes_now / 30) * 30
end_plan = min(end_record, self.forecast_minutes) + minute_now_align
rowspan = 0
in_span = False
start_span = False
for minute in range(minute_now_align, end_plan, 30):
minute_relative = minute - self.minutes_now
minute_relative_start = max(minute_relative, 0)
minute_start = minute_relative_start + self.minutes_now
minute_relative_end = minute_relative + 30
minute_end = minute_relative_end + self.minutes_now
minute_relative_slot_end = minute_relative_end
minute_timestamp = self.midnight_utc + timedelta(minutes=(minute_relative_start + self.minutes_now))
rate_start = minute_timestamp
rate_value_import = self.dp2(self.rate_import.get(minute, 0))
rate_value_export = self.dp2(self.rate_export.get(minute, 0))
charge_window_n = -1
discharge_window_n = -1
import_cost_threshold = self.rate_import_cost_threshold
export_cost_threshold = self.rate_export_cost_threshold
if self.rate_best_cost_threshold_charge:
import_cost_threshold = self.rate_best_cost_threshold_charge
if self.rate_best_cost_threshold_discharge:
export_cost_threshold = self.rate_best_cost_threshold_discharge
show_limit = ""
for try_minute in range(minute_start, minute_end, PREDICT_STEP):
charge_window_n = self.in_charge_window(self.charge_window_best, try_minute)
if charge_window_n >= 0:
break
for try_minute in range(minute_start, minute_end, PREDICT_STEP):
discharge_window_n = self.in_charge_window(self.discharge_window_best, try_minute)
if discharge_window_n >= 0:
break
start_span = False
if in_span:
rowspan = max(rowspan - 1, 0)
if rowspan == 0:
in_span = False
if charge_window_n >= 0 and not in_span:
rowspan = int((self.charge_window_best[charge_window_n]["end"] - minute) / 30)
if rowspan > 1 and (discharge_window_n < 0):
in_span = True
start_span = True
minute_relative_end = self.charge_window_best[charge_window_n]["end"] - minute_now_align
else:
rowspan = 0
if discharge_window_n >= 0 and not in_span:
rowspan = int((self.discharge_window_best[discharge_window_n]["end"] - minute) / 30)
start = self.discharge_window_best[discharge_window_n]["start"]
if start <= minute and rowspan > 1 and (charge_window_n < 0):
in_span = True
start_span = True
minute_relative_end = self.discharge_window_best[discharge_window_n]["end"] - minute_now_align
else:
rowspan = 0
pv_forecast = 0
load_forecast = 0
pv_forecast10 = 0
load_forecast10 = 0
for offset in range(minute_relative_start, minute_relative_slot_end, PREDICT_STEP):
pv_forecast += pv_forecast_minute_step.get(offset, 0.0)
load_forecast += load_minutes_step.get(offset, 0.0)
pv_forecast10 += pv_forecast_minute_step10.get(offset, 0.0)
load_forecast10 += load_minutes_step10.get(offset, 0.0)
pv_forecast = self.dp2(pv_forecast)
load_forecast = self.dp2(load_forecast)
pv_forecast10 = self.dp2(pv_forecast10)
load_forecast10 = self.dp2(load_forecast10)
soc_percent = calc_percent_limit(self.predict_soc_best.get(minute_relative_start, 0.0), self.soc_max)
soc_percent_end = calc_percent_limit(self.predict_soc_best.get(minute_relative_slot_end, 0.0), self.soc_max)
soc_percent_end_window = calc_percent_limit(self.predict_soc_best.get(minute_relative_end, 0.0), self.soc_max)
soc_percent_max = max(soc_percent, soc_percent_end)
soc_percent_min = min(soc_percent, soc_percent_end)
soc_percent_max_window = max(soc_percent, soc_percent_end_window)
soc_percent_min_window = min(soc_percent, soc_percent_end_window)
soc_change = self.predict_soc_best.get(minute_relative_slot_end, 0.0) - self.predict_soc_best.get(minute_relative_start, 0.0)
metric_start = self.predict_metric_best.get(minute_relative_start, 0.0)
metric_end = self.predict_metric_best.get(minute_relative_slot_end, metric_start)
metric_change = metric_end - metric_start
soc_sym = ""
if abs(soc_change) < 0.05:
soc_sym = "→"
elif soc_change >= 0:
soc_sym = "↗"
else:
soc_sym = "↘"
state = soc_sym
state_color = "#FFFFFF"
if minute in self.manual_idle_times:
state += " ⅎ"
pv_color = "#BCBCBC"
load_color = "#FFFFFF"
rate_color_import = "#FFFFAA"
rate_color_export = "#FFFFFF"
soc_color = "#3AEE85"
pv_symbol = ""
split = False
if soc_percent < 20.0:
soc_color = "#F18261"
elif soc_percent < 50.0:
soc_color = "#FFFF00"
if pv_forecast >= 0.2:
pv_color = "#FFAAAA"
pv_symbol = "☀"
elif pv_forecast >= 0.1:
pv_color = "#FFFF00"
pv_symbol = "☀"
elif pv_forecast == 0.0:
pv_forecast = ""
pv_forecast = str(pv_forecast)
if plan_debug and pv_forecast10 > 0.0:
pv_forecast += " (%s)" % (str(pv_forecast10))
if load_forecast >= 0.5:
load_color = "#F18261"
elif load_forecast >= 0.25:
load_color = "#FFFF00"
elif load_forecast > 0.0:
load_color = "#AAFFAA"
load_forecast = str(load_forecast)
if plan_debug and load_forecast10 > 0.0:
load_forecast += " (%s)" % (str(load_forecast10))
if rate_value_import <= 0: # colour the import rate, blue for negative, then green, yellow and red
rate_color_import = "#74C1FF"
elif rate_value_import <= import_cost_threshold:
rate_color_import = "#3AEE85"
elif rate_value_import > (import_cost_threshold * 1.5):
rate_color_import = "#F18261"
if rate_value_export >= (1.5 * export_cost_threshold):
rate_color_export = "#F18261"
elif rate_value_export >= export_cost_threshold:
rate_color_export = "#FFFFAA"
if charge_window_n >= 0:
limit = self.charge_limit_best[charge_window_n]
limit_percent = int(self.charge_limit_percent_best[charge_window_n])
if limit > 0.0:
if self.set_charge_freeze and (limit == self.reserve):
state = "FrzChrg→"
state_color = "#EEEEEE"
limit_percent = soc_percent
elif limit_percent == soc_percent_min_window:
state = "HoldChrg→"
state_color = "#34DBEB"
elif limit_percent < soc_percent_min_window:
state = "NoChrg↘"
state_color = "#FFFFFF"
else:
state = "Chrg↗"
state_color = "#3AEE85"
if self.charge_window_best[charge_window_n]["start"] in self.manual_charge_times:
state += " ⅎ"
elif self.charge_window_best[charge_window_n]["start"] in self.manual_freeze_charge_times:
state += " ⅎ"
show_limit = str(limit_percent)
else:
if discharge_window_n >= 0:
start = self.discharge_window_best[discharge_window_n]["start"]
if start > minute:
soc_change_this = self.predict_soc_best.get(max(start - self.minutes_now, 0), 0.0) - self.predict_soc_best.get(minute_relative_start, 0.0)
if soc_change_this >= 0:
state = " ↗"
elif soc_change_this < 0:
state = " ↘"
else:
state = " →"
state_color = "#FFFFFF"
show_limit = ""
if discharge_window_n >= 0:
limit = self.discharge_limits_best[discharge_window_n]
if limit == 99:
if state == soc_sym:
state = ""
if state:
state += "" # charging and freeze discharging in same slot, split the state into two
split = True
else:
state_color = "#AAAAAA"
state += "FrzDis→"
show_limit = str(int(limit))
elif limit < 100:
if state == soc_sym:
state = ""
if state:
state += " | " # charging and discharging in the same slot
split = True
else:
state_color = "#FFFF00"
if limit > soc_percent_max_window:
state += "HoldDis↘"
else:
state += "Dis↘"
show_limit = str(int(limit))
if self.discharge_window_best[discharge_window_n]["start"] in self.manual_discharge_times:
state += " ⅎ"
elif self.discharge_window_best[discharge_window_n]["start"] in self.manual_freeze_discharge_times:
state += " ⅎ"
# Import and export rates -> to string
adjust_type = self.rate_import_replicated.get(minute, None)
adjust_symbol = self.adjust_symbol(adjust_type)
if adjust_symbol:
rate_str_import = "%02.02f %s" % (rate_value_import, adjust_symbol)
else:
rate_str_import = "%02.02f" % (rate_value_import)
if plan_debug:
rate_str_import += " (%02.02f)" % (rate_value_import / self.battery_loss / self.inverter_loss + self.metric_battery_cycle)
if charge_window_n >= 0:
rate_str_import = "" + rate_str_import + ""
adjust_type = self.rate_export_replicated.get(minute, None)
adjust_symbol = self.adjust_symbol(adjust_type)
if adjust_symbol:
rate_str_export = "%02.02f %s" % (rate_value_export, adjust_symbol)
else:
rate_str_export = "%02.02f" % (rate_value_export)
if plan_debug:
rate_str_export += " (%02.02f)" % (rate_value_export * self.battery_loss_discharge * self.inverter_loss - self.metric_battery_cycle)
if discharge_window_n >= 0:
rate_str_export = "" + rate_str_export + ""
# Total cost at start of slot, add leading minus if negative
if metric_start >= 0:
total_str = self.currency_symbols[0] + "%02.02f" % (metric_start / 100.0)
else:
total_str = "-" + self.currency_symbols[0] + "%02.02f" % (abs(metric_start) / 100.0)
# Cost predicted for this slot
if metric_change >= 10.0:
cost_str = "+%d %s " % (int(metric_change), self.currency_symbols[1])
cost_str += " ↗"
cost_color = "#F18261"
elif metric_change >= 0.5:
cost_str = "+%d %s " % (int(metric_change), self.currency_symbols[1])
cost_str += " ↗"
cost_color = "#FFFF00"
elif metric_change <= -0.5:
cost_str = "-%d %s " % (int(abs(metric_change)), self.currency_symbols[1])
cost_str += " ↘"
cost_color = "#3AEE85"
else:
cost_str = " →"
cost_color = "#FFFFFF"
# Car charging?
if self.num_cars > 0:
car_charging_kwh = self.car_charge_slot_kwh(minute_start, minute_end)
if car_charging_kwh > 0.0:
car_charging_str = str(car_charging_kwh)
car_color = "FFFF00"
else:
car_charging_str = ""
car_color = "#FFFFFF"
# iBoost
iboost_amount_str = ""
iboost_color = "#FFFFFF"
if self.iboost_enable:
iboost_slot_end = minute_relative_slot_end
iboost_amount = self.predict_iboost_best.get(minute_relative_start, 0)
iboost_amount_end = self.predict_iboost_best.get(minute_relative_slot_end, 0)
iboost_amount_prev = self.predict_iboost_best.get(minute_relative_slot_end - PREDICT_STEP, 0)
if iboost_amount_prev > iboost_amount_end:
# Reset condition, scale to full slot size as last 5 minutes is missing in data
iboost_change = (
(iboost_amount_prev - iboost_amount)
* (minute_relative_slot_end - minute_relative_start)
/ (minute_relative_slot_end - PREDICT_STEP - minute_relative_start)
)
else:
iboost_change = max(iboost_amount_end - iboost_amount, 0.0)
if iboost_change > 0:
iboost_color = "#FFFF00"
iboost_amount_str = str(self.dp2(iboost_amount)) + " (+" + str(self.dp2(iboost_change)) + ")"
else:
if iboost_amount > 0:
iboost_amount_str = str(self.dp2(iboost_amount))
if self.carbon_enable:
# Work out carbon intensity and carbon use
carbon_amount = self.predict_carbon_best.get(minute_relative_start, 0)
carbon_amount_end = self.predict_carbon_best.get(minute_relative_slot_end, 0)
carbon_change = carbon_amount_end - carbon_amount
carbon_change = self.dp2(carbon_change)
carbon_intensity = self.dp0(self.carbon_intensity.get(minute_relative_start, 0))
if carbon_intensity >= 450:
carbon_intensity_color = "#8B0000"
elif carbon_intensity >= 290:
carbon_intensity_color = "#FF0000"
elif carbon_intensity >= 200:
carbon_intensity_color = "#FFA500"
elif carbon_intensity >= 120:
carbon_intensity_color = "#FFFF00"
elif carbon_intensity >= 40:
carbon_intensity_color = "#90EE90"
else:
carbon_intensity_color = "#00FF00"
carbon_str = str(self.dp2(carbon_amount / 1000.0))
if carbon_change >= 10:
carbon_str += " ↗"
carbon_color = "#FFAA00"
elif carbon_change <= -10:
carbon_str += " ↘"
carbon_color = "#00FF00"
else:
carbon_str += " →"
carbon_color = "#FFFFFF"
# Table row
html += ' | '
html += "| " + rate_start.strftime("%a %H:%M") + " | "
html += "" + str(rate_str_import) + " | "
html += "" + str(rate_str_export) + " | "
if start_span:
if split: # for slots that are both charging and discharging, just output the (split cell) state
html += "" + state + " | "
html += " " + show_limit + " | "
elif not in_span:
if split:
html += "" + state + " | "
html += " " + show_limit + " | "
html += "" + str(pv_forecast) + pv_symbol + " | "
html += "" + str(load_forecast) + " | "
if self.num_cars > 0: # Don't display car charging data if there's no car
html += "" + car_charging_str + " | "
if self.iboost_enable:
html += "" + iboost_amount_str + " | "
html += "" + str(soc_percent) + soc_sym + " | "
html += "" + str(cost_str) + " | "
html += "" + str(total_str) + " | "
if self.carbon_enable:
html += "" + str(carbon_intensity) + " | "
html += " " + str(carbon_str) + " | "
html += "
"
html += "
"
self.dashboard_item(self.prefix + ".plan_html", state="", attributes={"html": html, "friendly_name": "Plan in HTML", "icon": "mdi:web-box"})
def publish_rates(self, rates, export, gas=False):
"""
Publish the rates for charts
Create rates/time every 30 minutes
"""
rates_time = {}
for minute in range(-24 * 60, self.minutes_now + self.forecast_minutes + 24 * 60, 30):
minute_timestamp = self.midnight_utc + timedelta(minutes=minute)
stamp = minute_timestamp.strftime(TIME_FORMAT)
rates_time[stamp] = self.dp2(rates[minute])
if export:
self.publish_rates_export()
elif gas:
pass
else:
self.publish_rates_import()
if export:
self.dashboard_item(
self.prefix + ".rates_export",
state=self.dp2(rates[self.minutes_now]),
attributes={
"min": self.dp2(self.rate_export_min),
"max": self.dp2(self.rate_export_max),
"average": self.dp2(self.rate_export_average),
"threshold": self.dp2(self.rate_export_cost_threshold),
"results": self.filtered_times(rates_time),
"friendly_name": "Export rates",
"state_class": "measurement",
"unit_of_measurement": self.currency_symbols[1],
"icon": "mdi:currency-usd",
},
)
elif gas:
self.dashboard_item(
self.prefix + ".rates_gas",
state=self.dp2(rates[self.minutes_now]),
attributes={
"min": self.dp2(self.rate_gas_min),
"max": self.dp2(self.rate_gas_max),
"average": self.dp2(self.rate_gas_average),
"results": self.filtered_times(rates_time),
"friendly_name": "Gas rates",
"state_class": "measurement",
"unit_of_measurement": self.currency_symbols[1],
"icon": "mdi:currency-usd",
},
)
else:
self.dashboard_item(
self.prefix + ".rates",
state=self.dp2(rates[self.minutes_now]),
attributes={
"min": self.dp2(self.rate_min),
"max": self.dp2(self.rate_max),
"average": self.dp2(self.rate_average),
"threshold": self.dp2(self.rate_import_cost_threshold),
"results": self.filtered_times(rates_time),
"friendly_name": "Import rates",
"state_class": "measurement",
"unit_of_measurement": self.currency_symbols[1],
"icon": "mdi:currency-usd",
},
)
return rates
def today_cost(self, import_today, export_today):
"""
Work out energy costs today (approx)
"""
day_cost = 0
day_cost_import = 0
day_cost_export = 0
day_energy = 0
day_energy_export = 0
day_cost_time = {}
day_cost_time_import = {}
day_cost_time_export = {}
day_carbon_time = {}
carbon_g = 0
for minute in range(self.minutes_now):
# Add in standing charge
if (minute % (24 * 60)) == 0:
day_cost += self.metric_standing_charge
day_cost_import += self.metric_standing_charge
minute_back = self.minutes_now - minute - 1
energy = 0
energy = self.get_from_incrementing(import_today, minute_back)
if export_today:
energy_export = self.get_from_incrementing(export_today, minute_back)
else:
energy_export = 0
day_energy += energy
day_energy_export += energy_export
if self.rate_import:
day_cost += self.rate_import[minute] * energy
day_cost_import += self.rate_import[minute] * energy
if self.rate_export:
day_cost -= self.rate_export[minute] * energy_export
day_cost_export -= self.rate_export[minute] * energy_export
if self.carbon_enable:
carbon_g += self.carbon_history.get(minute_back, 0) * energy
carbon_g -= self.carbon_history.get(minute_back, 0) * energy_export
if (minute % 5) == 0:
minute_timestamp = self.midnight_utc + timedelta(minutes=minute)
stamp = minute_timestamp.strftime(TIME_FORMAT)
day_cost_time[stamp] = self.dp2(day_cost)
day_cost_time_import[stamp] = self.dp2(day_cost_import)
day_cost_time_export[stamp] = self.dp2(day_cost_export)
day_carbon_time[stamp] = self.dp2(carbon_g)
self.dashboard_item(
self.prefix + ".cost_today",
state=self.dp2(day_cost),
attributes={
"results": self.filtered_times(day_cost_time),
"friendly_name": "Cost so far today",
"state_class": "measurement",
"unit_of_measurement": self.currency_symbols[1],
"icon": "mdi:currency-usd",
},
)
if self.carbon_enable:
self.dashboard_item(
self.prefix + ".carbon_today",
state=self.dp2(carbon_g),
attributes={
"results": self.filtered_times(day_carbon_time),
"friendly_name": "Carbon today so far",
"state_class": "measurement",
"unit_of_measurement": "g",
"icon": "mdi:carbon-molecule",
},
)
self.dashboard_item(
self.prefix + ".cost_today_import",
state=self.dp2(day_cost_import),
attributes={
"results": self.filtered_times(day_cost_time_import),
"friendly_name": "Cost so far today import",
"state_class": "measurement",
"unit_of_measurement": self.currency_symbols[1],
"icon": "mdi:currency-usd",
},
)
self.dashboard_item(
self.prefix + ".cost_today_export",
state=self.dp2(day_cost_export),
attributes={
"results": self.filtered_times(day_cost_time_export),
"friendly_name": "Cost so far today export",
"state_class": "measurement",
"unit_of_measurement": self.currency_symbols[1],
"icon": "mdi:currency-usd",
},
)
self.log(
"Today's energy import {} kWh export {} kWh cost {} {} import {} {} export {} {} carbon {} kg".format(
self.dp2(day_energy),
self.dp2(day_energy_export),
self.dp2(day_cost),
self.currency_symbols[1],
self.dp2(day_cost_import),
self.currency_symbols[1],
self.dp2(day_cost_export),
self.currency_symbols[1],
self.dp2(carbon_g / 1000.0),
)
)
return day_cost, carbon_g
def publish_discharge_limit(self, discharge_window, discharge_limits, best):
"""
Create entity to chart discharge limit
Args:
discharge_window (list): List of dictionaries representing the discharge window.
discharge_limits (list): List of discharge limits in percent.
best (bool): Flag indicating whether to push as base or as best
Returns:
None
"""
discharge_limit_time = {}
discharge_limit_time_kw = {}
discharge_limit_soc = self.soc_max
discharge_limit_percent = 100
discharge_limit_first = False
prev_limit = -1
for minute in range(0, self.forecast_minutes + self.minutes_now, 5):
window_n = self.in_charge_window(discharge_window, minute)
minute_timestamp = self.midnight_utc + timedelta(minutes=minute)
stamp = minute_timestamp.strftime(TIME_FORMAT)
if window_n >= 0 and (discharge_limits[window_n] < 100.0):
soc_perc = discharge_limits[window_n]
soc_kw = (soc_perc * self.soc_max) / 100.0
if not discharge_limit_first:
discharge_limit_soc = soc_kw
discharge_limit_percent = discharge_limits[window_n]
discharge_limit_first = True
else:
soc_perc = 100
soc_kw = self.soc_max
if prev_limit != soc_perc:
discharge_limit_time[stamp] = soc_perc
discharge_limit_time_kw[stamp] = self.dp2(soc_kw)
prev_limit = soc_perc
discharge_start_str = ""
discharge_end_str = ""
discharge_start_date = None
discharge_end_date = None
discharge_average = None
discharge_start_in_minutes = self.forecast_minutes
discharge_end_in_minutes = self.forecast_minutes
if discharge_window and (discharge_window[0]["end"] < (24 * 60 + self.minutes_now)):
discharge_start_minutes = discharge_window[0]["start"]
discharge_end_minutes = discharge_window[0]["end"]
discharge_average = discharge_window[0].get("average", None)
discharge_start_in_minutes = max(discharge_start_minutes - self.minutes_now, 0)
discharge_end_in_minutes = max(discharge_end_minutes - self.minutes_now, 0)
time_format_time = "%H:%M:%S"
discharge_startt = self.midnight_utc + timedelta(minutes=discharge_start_minutes)
discharge_endt = self.midnight_utc + timedelta(minutes=discharge_end_minutes)
discharge_start_str = discharge_startt.strftime(time_format_time)
discharge_end_str = discharge_endt.strftime(time_format_time)
discharge_start_date = discharge_startt.strftime(TIME_FORMAT)
discharge_end_date = discharge_endt.strftime(TIME_FORMAT)
if best:
self.dashboard_item(
self.prefix + ".best_discharge_limit_kw",
state=self.dp2(discharge_limit_soc),
attributes={
"results": self.filtered_times(discharge_limit_time_kw),
"friendly_name": "Predicted discharge limit kWh best",
"state_class": "measurement",
"unit_of_measurement": "kWh",
"icon": "mdi:battery-charging",
},
)
self.dashboard_item(
self.prefix + ".best_discharge_limit",
state=discharge_limit_percent,
attributes={
"results": self.filtered_times(discharge_limit_time),
"rate": discharge_average,
"friendly_name": "Predicted discharge limit best",
"state_class": "measurement",
"unit_of_measurement": "%",
"icon": "mdi:battery-charging",
},
)
self.dashboard_item(
self.prefix + ".best_discharge_start",
state=discharge_start_str,
attributes={
"minutes_to": discharge_start_in_minutes,
"timestamp": discharge_start_date,
"friendly_name": "Predicted discharge start time best",
"device_class": "timestamp",
"state_class": None,
"unit_of_measurement": None,
"icon": "mdi:table-clock",
"rate": discharge_average,
},
)
self.dashboard_item(
self.prefix + ".best_discharge_end",
state=discharge_end_str,
attributes={
"minutes_to": discharge_end_in_minutes,
"timestamp": discharge_end_date,
"friendly_name": "Predicted discharge end time best",
"device_class": "timestamp",
"state_class": None,
"unit_of_measurement": None,
"icon": "mdi:table-clock",
"rate": discharge_average,
},
)
else:
self.dashboard_item(
self.prefix + ".discharge_limit_kw",
state=self.dp2(discharge_limit_soc),
attributes={
"results": self.filtered_times(discharge_limit_time_kw),
"friendly_name": "Predicted discharge limit kWh",
"state_class": "measurement",
"unit_of_measurement": "kWh",
"icon": "mdi:battery-charging",
},
)
self.dashboard_item(
self.prefix + ".discharge_limit",
state=discharge_limit_percent,
attributes={
"results": self.filtered_times(discharge_limit_time),
"rate": discharge_average,
"friendly_name": "Predicted discharge limit",
"state_class": "measurement",
"unit_of_measurement": "%",
"icon": "mdi:battery-charging",
},
)
self.dashboard_item(
self.prefix + ".discharge_start",
state=discharge_start_str,
attributes={
"minutes_to": discharge_start_in_minutes,
"timestamp": discharge_start_date,
"friendly_name": "Predicted discharge start time",
"device_class": "timestamp",
"state_class": None,
"unit_of_measurement": None,
"icon": "mdi:table-clock",
"rate": discharge_average,
},
)
self.dashboard_item(
self.prefix + ".discharge_end",
state=discharge_end_str,
attributes={
"minutes_to": discharge_end_in_minutes,
"timestamp": discharge_end_date,
"friendly_name": "Predicted discharge end time",
"device_class": "timestamp",
"state_class": None,
"unit_of_measurement": None,
"icon": "mdi:table-clock",
"rate": discharge_average,
},
)
def publish_charge_limit(self, charge_limit, charge_window, charge_limit_percent, best=False, soc={}):
"""
Create entity to chart charge limit
Parameters:
- charge_limit (list): List of charge limits in kWh
- charge_window (list): List of charge window dictionaries
- charge_limit_percent (list): List of charge limit percentages
- best (bool, optional): Flag indicating if we publish as base or as best
- soc (dict, optional): Dictionary of the predicted SOC over time
"""
charge_limit_time = {}
charge_limit_time_kw = {}
prev_perc = -1
for minute in range(0, self.forecast_minutes + self.minutes_now, 5):
window = self.in_charge_window(charge_window, minute)
minute_timestamp = self.midnight_utc + timedelta(minutes=minute)
stamp = minute_timestamp.strftime(TIME_FORMAT)
if window >= 0:
soc_perc = charge_limit_percent[window]
soc_kw = charge_limit[window]
else:
soc_perc = 0
soc_kw = 0
# Convert % of charge freeze to current SOC number
if self.set_charge_freeze and (soc_perc == self.reserve_percent):
offset = int((minute - self.minutes_now) / 5) * 5
soc_kw = soc.get(offset, soc_kw)
if prev_perc != soc_perc:
charge_limit_time[stamp] = soc_perc
charge_limit_time_kw[stamp] = soc_kw
prev_perc = soc_perc
charge_limit_first = 0
charge_limit_percent_first = 0
charge_average_first = None
charge_start_str = ""
charge_end_str = ""
charge_start_date = None
charge_end_date = None
charge_start_in_minutes = self.forecast_days * 24 * 60
charge_end_in_minutes = self.forecast_days * 24 * 60
if charge_limit and charge_window[0]["end"] <= (24 * 60 + self.minutes_now):
charge_limit_first = charge_limit[0]
charge_limit_percent_first = charge_limit_percent[0]
charge_start_minutes = charge_window[0]["start"]
charge_end_minutes = charge_window[0]["end"]
charge_average_first = charge_window[0].get("average", None)
charge_start_in_minutes = max(charge_start_minutes - self.minutes_now, 0)
charge_end_in_minutes = max(charge_end_minutes - self.minutes_now, 0)
time_format_time = "%H:%M:%S"
charge_startt = self.midnight_utc + timedelta(minutes=charge_start_minutes)
charge_endt = self.midnight_utc + timedelta(minutes=charge_end_minutes)
charge_start_str = charge_startt.strftime(time_format_time)
charge_end_str = charge_endt.strftime(time_format_time)
charge_start_date = charge_startt.strftime(TIME_FORMAT)
charge_end_date = charge_endt.strftime(TIME_FORMAT)
if best:
self.dashboard_item(
self.prefix + ".best_charge_limit_kw",
state=self.dp2(charge_limit_first),
attributes={
"results": self.filtered_times(charge_limit_time_kw),
"friendly_name": "Predicted charge limit kWh best",
"state_class": "measurement",
"unit_of_measurement": "kWh",
"icon": "mdi:battery-charging",
},
)
self.dashboard_item(
self.prefix + ".best_charge_limit",
state=charge_limit_percent_first,
attributes={
"results": self.filtered_times(charge_limit_time),
"friendly_name": "Predicted charge limit best",
"state_class": "measurement",
"unit_of_measurement": "%",
"icon": "mdi:battery-charging",
"rate": charge_average_first,
},
)
self.dashboard_item(
self.prefix + ".best_charge_start",
state=charge_start_str,
attributes={
"timestamp": charge_start_date,
"minutes_to": charge_start_in_minutes,
"friendly_name": "Predicted charge start time best",
"device_class": "timestamp",
"state_class": None,
"unit_of_measurement": None,
"icon": "mdi:table-clock",
"rate": charge_average_first,
},
)
self.dashboard_item(
self.prefix + ".best_charge_end",
state=charge_end_str,
attributes={
"timestamp": charge_end_date,
"minutes_to": charge_end_in_minutes,
"friendly_name": "Predicted charge end time best",
"device_class": "timestamp",
"state_class": None,
"unit_of_measurement": None,
"icon": "mdi:table-clock",
"rate": charge_average_first,
},
)
else:
self.dashboard_item(
self.prefix + ".charge_limit_kw",
state=self.dp2(charge_limit_first),
attributes={
"results": self.filtered_times(charge_limit_time_kw),
"friendly_name": "Predicted charge limit kWh",
"state_class": "measurement",
"unit_of_measurement": "kWh",
"icon": "mdi:battery-charging",
},
)
self.dashboard_item(
self.prefix + ".charge_limit",
state=charge_limit_percent_first,
attributes={
"results": self.filtered_times(charge_limit_time),
"friendly_name": "Predicted charge limit",
"state_class": "measurement",
"unit_of_measurement": "%",
"icon": "mdi:battery-charging",
"rate": charge_average_first,
},
)
self.dashboard_item(
self.prefix + ".charge_start",
state=charge_start_str,
attributes={
"timestamp": charge_start_date,
"minutes_to": charge_start_in_minutes,
"friendly_name": "Predicted charge start time",
"device_class": "timestamp",
"state_class": None,
"unit_of_measurement": None,
"icon": "mdi:table-clock",
"rate": charge_average_first,
},
)
self.dashboard_item(
self.prefix + ".charge_end",
state=charge_end_str,
attributes={
"timestamp": charge_end_date,
"minutes_to": charge_end_in_minutes,
"friendly_name": "Predicted charge end time",
"device_class": "timestamp",
"state_class": None,
"unit_of_measurement": None,
"icon": "mdi:table-clock",
"rate": charge_average_first,
},
)
def reset(self):
"""
Init stub
"""
reset_prediction_globals()
self.define_service_list()
self.stop_thread = False
self.solcast_api_limit = None
self.solcast_api_used = None
self.currency_symbols = self.args.get("currency_symbols", "£p")
self.pool = None
self.watch_list = []
self.restart_active = False
self.inverter_needs_reset = False
self.inverter_needs_reset_force = ""
self.manual_charge_times = []
self.manual_discharge_times = []
self.manual_freeze_charge_times = []
self.manual_freeze_discharge_times = []
self.manual_idle_times = []
self.manual_all_times = []
self.config_index = {}
self.dashboard_index = []
self.prefix = self.args.get("prefix", "predbat")
self.previous_status = None
self.had_errors = False
self.expert_mode = False
self.plan_valid = False
self.plan_last_updated = None
self.plan_last_updated_minutes = 0
self.calculate_plan_every = 5
self.prediction_started = False
self.update_pending = True
self.midnight_utc = None
self.difference_minutes = 0
self.minutes_to_midnight = 0
self.days_previous = [7]
self.days_previous_weight = [1]
self.forecast_days = 0
self.forecast_minutes = 0
self.soc_kw = 0
self.soc_max = 10.0
self.end_record = 24 * 60 * 2
self.predict_soc = {}
self.predict_soc_best = {}
self.predict_iboost_best = {}
self.predict_metric_best = {}
self.metric_min_improvement = 0.0
self.metric_min_improvement_discharge = 0.0
self.metric_battery_cycle = 0.0
self.metric_battery_value_scaling = 1.0
self.metric_future_rate_offset_import = 0.0
self.metric_future_rate_offset_export = 0.0
self.metric_inday_adjust_damping = 1.0
self.metric_standing_charge = 0.0
self.metric_self_sufficiency = 0.0
self.iboost_value_scaling = 1.0
self.rate_import = {}
self.rate_export = {}
self.rate_gas = {}
self.rate_slots = []
self.low_rates = []
self.high_export_rates = []
self.cost_today_sofar = 0
self.carbon_today_sofar = 0
self.octopus_slots = []
self.car_charging_slots = []
self.reserve = 0
self.reserve_current = 0
self.battery_loss = 1.0
self.battery_loss_discharge = 1.0
self.inverter_loss = 1.0
self.inverter_hybrid = True
self.inverter_soc_reset = False
self.inverter_set_charge_before = True
self.best_soc_min = 0
self.best_soc_max = 0
self.best_soc_margin = 0
self.best_soc_keep = 0
self.rate_min = 0
self.rate_min_minute = 0
self.rate_min_forward = {}
self.rate_max = 0
self.rate_max_minute = 0
self.rate_export_cost_threshold = 99
self.rate_import_cost_threshold = 99
self.rate_best_cost_threshold_charge = None
self.rate_best_cost_threshold_discharge = None
self.rate_average = 0
self.rate_export_min = 0
self.rate_export_min_minute = 0
self.rate_export_max = 0
self.rate_export_max_minute = 0
self.rate_export_average = 0
self.rate_gas_min = 0
self.rate_gas_max = 0
self.rate_gas_average = 0
self.rate_gas_min_minute = 0
self.rate_gas_max_minute = 0
self.set_soc_minutes = 30
self.set_window_minutes = 30
self.debug_enable = False
self.import_today = {}
self.import_today_now = 0
self.export_today = {}
self.export_today_now = 0
self.pv_today = {}
self.pv_today_now = 0
self.pv_power = 0
self.load_power = 0
self.io_adjusted = {}
self.current_charge_limit = 0.0
self.charge_limit = []
self.charge_limit_percent = []
self.charge_limit_best = []
self.charge_limit_best_percent = []
self.charge_window = []
self.charge_window_best = []
self.car_charging_battery_size = [100]
self.car_charging_limit = [100]
self.car_charging_soc = [0]
self.car_charging_soc_next = [None]
self.car_charging_rate = [7.4]
self.car_charging_loss = 1.0
self.discharge_window = []
self.discharge_limits = []
self.discharge_limits_best = []
self.discharge_window_best = []
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_rate_min = 0
self.battery_rate_max_scaling = 1.0
self.battery_rate_max_scaling_discharge = 1.0
self.charge_rate_now = 0
self.discharge_rate_now = 0
self.car_charging_hold = False
self.car_charging_manual_soc = False
self.car_charging_threshold = 99
self.car_charging_energy = {}
self.notify_devices = ["notify"]
self.octopus_url_cache = {}
self.futurerate_url_cache = {}
self.ge_url_cache = {}
self.github_url_cache = {}
self.load_minutes = {}
self.load_minutes_now = 0
self.load_minutes_age = 0
self.battery_capacity_nominal = False
self.releases = {}
self.balance_inverters_enable = False
self.balance_inverters_charge = True
self.balance_inverters_discharge = True
self.balance_inverters_crosscharge = True
self.balance_inverters_threshold_charge = 1.0
self.balance_inverters_threshold_discharge = 1.0
self.load_inday_adjustment = 1.0
self.set_read_only = True
self.metric_cloud_coverage = 0.0
self.future_energy_rates_import = {}
self.future_energy_rates_export = {}
self.load_scaling_dynamic = {}
self.battery_charge_power_curve = {}
self.battery_charge_power_curve_auto = False
self.battery_discharge_power_curve = {}
self.battery_discharge_power_curve_auto = False
self.computed_charge_curve = False
self.computed_discharge_curve = False
self.isCharging = False
self.isDischarging = False
self.savings_today_predbat = 0.0
self.savings_today_predbat_soc = 0.0
self.savings_today_pvbat = 0.0
self.savings_today_actual = 0.0
self.yesterday_load_step = {}
self.yesterday_pv_step = {}
self.rate_import = {}
self.rate_import_replicated = {}
self.rate_export = {}
self.rate_export_replicated = {}
self.rate_slots = []
self.io_adjusted = {}
self.low_rates = []
self.high_export_rates = []
self.octopus_slots = []
self.cost_today_sofar = 0
self.carbon_today_sofar = 0
self.import_today = {}
self.export_today = {}
self.pv_today = {}
self.load_minutes = {}
self.load_minutes_age = 0
self.load_forecast = {}
self.pv_forecast_minute = {}
self.pv_forecast_minute10 = {}
self.load_scaling_dynamic = {}
self.carbon_intensity = {}
self.carbon_history = {}
self.iboost_enable = False
self.iboost_gas = False
self.iboost_gas_export = False
self.iboost_smart = False
self.iboost_on_discharge = False
self.iboost_prevent_discharge = False
self.iboost_smart_threshold = 0
self.iboost_rate_threshold = 9999
self.iboost_rate_threshold_export = 9999
self.iboost_plan = []
self.iboost_energy_subtract = True
self.iboost_running = False
self.iboost_running_full = False
self.iboost_running_solar = False
self.config_root = "./"
for root in CONFIG_ROOTS:
if os.path.exists(root):
self.config_root = root
break
self.config_root_p = self.config_root
self.log("Config root is {}".format(self.config_root))
def optimise_charge_limit_price_threads(
self,
price_set,
price_links,
window_index,
record_charge_windows,
record_discharge_windows,
try_charge_limit,
charge_window,
discharge_window,
discharge_limits,
end_record=None,
region_start=None,
region_end=None,
fast=False,
quiet=False,
best_metric=9999999,
best_cost=0,
best_keep=0,
best_soc_min=None,
best_price_charge=None,
best_price_discharge=None,
best_cycle=0,
best_carbon=0,
best_import=0,
tried_list=None,
test_mode=False,
):
"""
Pick an import price threshold which gives the best results
"""
loop_price = price_set[-1]
best_price = loop_price
try_discharge = discharge_limits.copy()
best_limits = try_charge_limit.copy()
best_discharge = try_discharge.copy()
if best_soc_min is None:
best_soc_min = self.reserve
if best_price_charge is None:
best_price_charge = price_set[-1]
if best_price_discharge is None:
best_price_discharge = price_set[0]
step = PREDICT_STEP
if fast:
step = 30
if tried_list is None:
tried_list = {}
if region_start:
region_txt = "Region {} - {}".format(self.time_abs_str(region_start), self.time_abs_str(region_end))
else:
region_txt = "All regions"
# Do we loop on discharge?
if self.calculate_best_discharge and self.calculate_discharge_first:
discharge_enable = True
else:
discharge_enable = False
# Most expensive first
all_prices = price_set[::] + [self.dp1(price_set[-1] - 1)]
if not quiet:
self.log("All prices {}".format(all_prices))
if region_start:
self.log("Region {} - {}".format(self.time_abs_str(region_start), self.time_abs_str(region_end)))
window_prices = {}
window_prices_discharge = {}
for loop_price in all_prices:
pred_table = []
for modulo in [2, 3, 4, 6, 8, 16, 32]:
for divide in [96, 48, 32, 16, 8, 4, 3, 2, 1]:
all_n = []
all_d = []
divide_count_d = 0
highest_price_charge = price_set[-1]
lowest_price_discharge = price_set[0]
for price in price_set:
links = price_links[price]
if loop_price >= price:
for key in links:
window_n = window_index[key]["id"]
typ = window_index[key]["type"]
if typ == "c":
window_prices[window_n] = price
all_n.append(window_n)
elif discharge_enable:
# For prices above threshold try discharge
for key in links:
typ = window_index[key]["type"]
window_n = window_index[key]["id"]
if typ == "d":
window_prices_discharge[window_n] = price
if (int(divide_count_d / divide) % modulo) == 0:
all_d.append(window_n)
divide_count_d += 1
# Sort for print out
all_n.sort()
all_d.sort()
# This price band setting for charge
try_charge_limit = best_limits.copy()
for window_n in range(record_charge_windows):
if window_n >= len(try_charge_limit):
continue
if region_start and (charge_window[window_n]["start"] > region_end or charge_window[window_n]["end"] < region_start):
continue
if window_n in all_n:
if window_prices[window_n] > highest_price_charge:
highest_price_charge = window_prices[window_n]
try_charge_limit[window_n] = self.soc_max
else:
try_charge_limit[window_n] = 0
# Try discharge on/off
try_discharge = best_discharge.copy()
for window_n in range(record_discharge_windows):
if window_n >= len(discharge_limits):
continue
if region_start and (discharge_window[window_n]["start"] > region_end or discharge_window[window_n]["end"] < region_start):
continue
try_discharge[window_n] = 100
if window_n in all_d:
if not self.calculate_discharge_oncharge:
hit_charge = self.hit_charge_window(self.charge_window_best, discharge_window[window_n]["start"], discharge_window[window_n]["end"])
if hit_charge >= 0 and try_charge_limit[hit_charge] > 0.0:
continue
if not self.car_charging_from_battery and self.hit_car_window(discharge_window[window_n]["start"], discharge_window[window_n]["end"]):
continue
if (
not self.iboost_on_discharge
and self.iboost_enable
and self.iboost_plan
and (self.hit_charge_window(self.iboost_plan, discharge_window[window_n]["start"], discharge_window[window_n]["end"]) >= 0)
):
continue
if window_prices_discharge[window_n] < lowest_price_discharge:
lowest_price_discharge = window_prices_discharge[window_n]
try_discharge[window_n] = 0
# Skip this one as it's the same as selected already
try_hash = str(try_charge_limit) + "_d_" + str(try_discharge)
if try_hash in tried_list:
if self.debug_enable and 0:
self.log(
"Skip this optimisation with divide {} windows {} discharge windows {} discharge_enable {} as it's the same as previous ones hash {}".format(
divide, all_n, all_d, discharge_enable, try_hash
)
)
continue
tried_list[try_hash] = True
pred_handle = self.launch_run_prediction_single(try_charge_limit, charge_window, discharge_window, try_discharge, False, end_record=end_record, step=step)
pred_item = {}
pred_item["handle"] = pred_handle
pred_item["charge_limit"] = try_charge_limit.copy()
pred_item["discharge_limit"] = try_discharge.copy()
pred_item["highest_price_charge"] = highest_price_charge
pred_item["lowest_price_discharge"] = lowest_price_discharge
pred_item["loop_price"] = loop_price
pred_table.append(pred_item)
for pred in pred_table:
handle = pred["handle"]
try_charge_limit = pred["charge_limit"]
try_discharge = pred["discharge_limit"]
highest_price_charge = pred["highest_price_charge"]
lowest_price_discharge = pred["lowest_price_discharge"]
loop_price = pred["loop_price"]
cost, import_kwh_battery, import_kwh_house, export_kwh, soc_min, soc, soc_min_minute, battery_cycle, metric_keep, final_iboost, final_carbon_g = handle.get()
metric = self.compute_metric(
end_record, soc, soc, cost, cost, final_iboost, final_iboost, battery_cycle, metric_keep, final_carbon_g, import_kwh_battery, import_kwh_house, export_kwh
)
# Optimise
if self.debug_enable:
if discharge_enable:
self.log(
"Optimise all for buy/sell price band <= {} divide {} modulo {} metric {} keep {} soc_min {} import {} export {} soc {} windows {} discharge on {}".format(
loop_price,
divide,
modulo,
self.dp4(metric),
self.dp4(metric_keep),
self.dp4(soc_min),
self.dp4(import_kwh_battery + import_kwh_house),
self.dp4(export_kwh),
self.dp4(soc),
try_charge_limit,
try_discharge,
)
)
else:
self.log(
"Optimise all for buy/sell price band <= {} divide {} modulo {} metric {} keep {} soc_min {} import {} export {} soc {} windows {} discharge off".format(
loop_price,
divide,
modulo,
self.dp4(metric),
self.dp4(metric_keep),
self.dp4(soc_min),
self.dp4(import_kwh_battery + import_kwh_house),
self.dp4(export_kwh),
self.dp4(soc),
try_charge_limit,
)
)
# For the first pass just pick the most cost effective threshold, consider soc keep later
if metric < best_metric:
best_metric = metric
best_keep = metric_keep
best_price = loop_price
best_price_charge = highest_price_charge
best_price_discharge = lowest_price_discharge
best_limits = try_charge_limit.copy()
best_discharge = try_discharge.copy()
best_cycle = battery_cycle
best_carbon = final_carbon_g
best_soc_min = soc_min
best_cost = cost
best_import = import_kwh_battery + import_kwh_house
if 1 or not quiet:
self.log(
"Optimise all charge found best buy/sell price band {} best price threshold {} at cost {} metric {} keep {} cycle {} carbon {} import {} cost {} limits {} discharge {}".format(
loop_price,
best_price_charge,
self.dp4(best_cost),
self.dp4(best_metric),
self.dp4(best_keep),
self.dp4(best_cycle),
self.dp0(best_carbon),
self.dp2(best_import),
self.dp4(best_cost),
best_limits,
best_discharge,
)
)
if self.debug_enable:
self.log(
"Optimise all charge {} best price threshold {} total simulations {} charges at {} at cost {} metric {} keep {} cycle {} carbon {} import {} cost {} soc_min {} limits {} discharge {}".format(
region_txt,
self.dp4(best_price),
len(tried_list),
self.dp4(best_price_charge),
self.dp4(best_cost),
self.dp4(best_metric),
self.dp4(best_keep),
self.dp4(best_cycle),
self.dp0(best_carbon),
self.dp2(best_import),
self.dp4(best_cost),
self.dp4(best_soc_min),
best_limits,
best_discharge,
)
)
if test_mode:
# Simulate with medium PV
(
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(best_limits, charge_window, discharge_window, best_discharge, False, end_record=end_record, step=PREDICT_STEP, save="test")
return (
best_limits,
best_discharge,
best_price_charge,
best_price_discharge,
best_metric,
best_cost,
best_keep,
best_soc_min,
best_cycle,
best_carbon,
best_import,
tried_list,
)
def launch_run_prediction_single(self, charge_limit, charge_window, discharge_window, discharge_limits, pv10, end_record, step=PREDICT_STEP):
"""
Launch a thread to run a prediction
"""
charge_limit = copy.deepcopy(charge_limit)
discharge_limits = copy.deepcopy(discharge_limits)
if self.pool and self.pool._state == "RUN":
han = self.pool.apply_async(wrapped_run_prediction_single, (charge_limit, charge_window, discharge_window, discharge_limits, pv10, end_record, step))
else:
han = DummyThread(self.prediction.thread_run_prediction_single(charge_limit, charge_window, discharge_window, discharge_limits, pv10, end_record, step))
return han
def launch_run_prediction_charge(self, loop_soc, window_n, charge_limit, charge_window, discharge_window, discharge_limits, pv10, all_n, end_record):
"""
Launch a thread to run a prediction
"""
if self.pool and self.pool._state == "RUN":
han = self.pool.apply_async(
wrapped_run_prediction_charge, (loop_soc, window_n, charge_limit, charge_window, discharge_window, discharge_limits, pv10, all_n, end_record)
)
else:
han = DummyThread(
self.prediction.thread_run_prediction_charge(loop_soc, window_n, charge_limit, charge_window, discharge_window, discharge_limits, pv10, all_n, end_record)
)
return han
def launch_run_prediction_discharge(self, this_discharge_limit, start, window_n, try_charge_limit, charge_window, try_discharge_window, try_discharge, pv10, all_n, end_record):
"""
Launch a thread to run a prediction
"""
if self.pool and self.pool._state == "RUN":
han = self.pool.apply_async(
wrapped_run_prediction_discharge,
(this_discharge_limit, start, window_n, try_charge_limit, charge_window, try_discharge_window, try_discharge, pv10, all_n, end_record),
)
else:
han = DummyThread(
self.prediction.thread_run_prediction_discharge(
this_discharge_limit, start, window_n, try_charge_limit, charge_window, try_discharge_window, try_discharge, pv10, all_n, end_record
)
)
return han
def compute_metric(
self, end_record, soc, soc10, cost, cost10, final_iboost, final_iboost10, battery_cycle, metric_keep, final_carbon_g, import_kwh_battery, import_kwh_house, export_kwh
):
"""
Compute the metric combing pv and pv10 data
"""
# Store simulated mid value
metric = cost
metric10 = cost10
# Balancing payment to account for battery left over
# ie. how much extra battery is worth to us in future, assume it's the same as low rate
rate_min = self.rate_min_forward.get(end_record, self.rate_min) / self.inverter_loss / self.battery_loss + self.metric_battery_cycle
rate_export_min = self.rate_export_min * self.inverter_loss * self.battery_loss_discharge - self.metric_battery_cycle - rate_min
metric -= (soc * self.metric_battery_value_scaling + final_iboost * self.iboost_value_scaling) * max(rate_min, 1.0, rate_export_min)
metric10 -= (soc10 * self.metric_battery_value_scaling + final_iboost10 * self.iboost_value_scaling) * max(rate_min, 1.0, rate_export_min)
# Metric adjustment based on 10% outcome weighting
if metric10 > metric:
metric_diff = metric10 - metric
metric_diff *= self.pv_metric10_weight
metric += metric_diff
# Carbon metric
if self.carbon_enable:
metric += (final_carbon_g / 1000) * self.carbon_metric
# Self sufficiency metric
metric += (import_kwh_house + import_kwh_battery) * self.metric_self_sufficiency
# Adjustment for battery cycles metric
metric += battery_cycle * self.metric_battery_cycle + metric_keep
return self.dp4(metric)
def optimise_charge_limit(self, window_n, record_charge_windows, charge_limit, charge_window, discharge_window, discharge_limits, all_n=None, end_record=None):
"""
Optimise a single charging window for best SOC
"""
loop_soc = self.soc_max
best_soc = self.soc_max
best_soc_min = 0
best_soc_min_minute = 0
best_metric = 9999999
best_cost = 0
best_import = 0
best_soc_step = self.best_soc_step
best_keep = 0
best_cycle = 0
best_carbon = 0
all_max_soc = self.soc_max
all_min_soc = 0
try_charge_limit = copy.deepcopy(charge_limit)
resultmid = {}
result10 = {}
# For single windows, if the size is 30 minutes or less then use a larger step
min_improvement_scaled = self.metric_min_improvement
if not all_n:
start = charge_window[window_n]["start"]
end = charge_window[window_n]["end"]
window_size = end - start
if window_size <= 30 and best_soc_step < 1:
best_soc_step *= 2
min_improvement_scaled = self.metric_min_improvement * window_size / 30.0
# Start the loop at the max soc setting
if self.best_soc_max > 0:
loop_soc = min(loop_soc, self.best_soc_max)
# Create min/max SOC to avoid simulating SOC that are not going have any impact
# Can't do this for anything but a single window as the winder SOC impact isn't known
if not all_n:
hans = []
all_max_soc = 0
all_min_soc = self.soc_max
hans.append(self.launch_run_prediction_charge(loop_soc, window_n, charge_limit, charge_window, discharge_window, discharge_limits, False, all_n, end_record))
hans.append(self.launch_run_prediction_charge(loop_soc, window_n, charge_limit, charge_window, discharge_window, discharge_limits, True, all_n, end_record))
hans.append(self.launch_run_prediction_charge(best_soc_min, window_n, charge_limit, charge_window, discharge_window, discharge_limits, False, all_n, end_record))
hans.append(self.launch_run_prediction_charge(best_soc_min, window_n, charge_limit, charge_window, discharge_window, discharge_limits, True, all_n, end_record))
id = 0
for han in hans:
(
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,
) = han.get()
all_min_soc = min(all_min_soc, min_soc)
all_max_soc = max(all_max_soc, max_soc)
if id == 0:
resultmid[loop_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,
min_soc,
max_soc,
]
elif id == 1:
result10[loop_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,
min_soc,
max_soc,
]
elif id == 2:
resultmid[best_soc_min] = [
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,
]
elif id == 3:
result10[best_soc_min] = [
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,
]
id += 1
# Assemble list of SOCs to try
try_socs = []
loop_step = max(best_soc_step, 0.1)
best_soc_min_setting = self.best_soc_min
if best_soc_min_setting > 0:
best_soc_min_setting = max(self.reserve, best_soc_min_setting)
while loop_soc > self.reserve:
skip = False
try_soc = max(best_soc_min, loop_soc)
try_soc = self.dp2(min(try_soc, self.soc_max))
if try_soc > (all_max_soc + loop_step):
skip = True
if (try_soc > self.reserve) and (try_soc > self.best_soc_min) and (try_soc < (all_min_soc - loop_step)):
skip = True
# Keep those we already simulated
if try_soc in resultmid:
skip = False
# Keep the current setting if different from the selected ones
if not all_n and try_soc == charge_limit[window_n]:
skip = False
# All to the list
if not skip and (try_soc not in try_socs) and (try_soc != self.reserve):
try_socs.append(self.dp2(try_soc))
loop_soc -= loop_step
# Give priority to off to avoid spurious charge freezes
if best_soc_min_setting not in try_socs:
try_socs.append(best_soc_min_setting)
if self.set_charge_freeze and (self.reserve not in try_socs):
try_socs.append(self.reserve)
# Run the simulations in parallel
results = []
results10 = []
for try_soc in try_socs:
if try_soc not in resultmid:
hanres = self.launch_run_prediction_charge(try_soc, window_n, charge_limit, charge_window, discharge_window, discharge_limits, False, all_n, end_record)
results.append(hanres)
hanres10 = self.launch_run_prediction_charge(try_soc, window_n, charge_limit, charge_window, discharge_window, discharge_limits, True, all_n, end_record)
results10.append(hanres10)
# Get results from sims if we simulated them
for try_soc in try_socs:
if try_soc not in resultmid:
hanres = results.pop(0)
hanres10 = results10.pop(0)
resultmid[try_soc] = hanres.get()
result10[try_soc] = hanres10.get()
window_results = {}
# Now we have all the results, we can pick the best SOC
for try_soc in try_socs:
window = charge_window[window_n]
# Store try value into the window, either all or just this one
if all_n:
for window_id in all_n:
try_charge_limit[window_id] = try_soc
else:
try_charge_limit[window_n] = try_soc
# Simulate with medium PV
(
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,
) = resultmid[try_soc]
(
cost10,
import_kwh_battery10,
import_kwh_house10,
export_kwh10,
soc_min10,
soc10,
soc_min_minute10,
battery_cycle10,
metric_keep10,
final_iboost10,
final_carbon_g10,
min_soc10,
max_soc10,
) = result10[try_soc]
# Compute the metric from simulation results
metric = self.compute_metric(
end_record, soc, soc10, cost, cost10, final_iboost, final_iboost10, battery_cycle, metric_keep, final_carbon_g, import_kwh_battery, import_kwh_house, export_kwh
)
# Metric adjustment based on current charge limit when inside the window
# to try to avoid constant small changes to SOC target by forcing to keep the current % during a charge period
# if changing it has little impact
if not all_n and self.isCharging and (window_n == self.in_charge_window(charge_window, self.minutes_now)) and (try_soc != self.reserve):
try_percent = calc_percent_limit(try_soc, self.soc_max)
compare_with = max(self.current_charge_limit, self.reserve_percent)
if compare_with == try_percent:
metric -= max(0.1, self.metric_min_improvement)
if try_soc == best_soc_min_setting:
# Minor weighting to 0%
metric -= 0.002
elif try_soc == self.soc_max or try_soc == self.reserve:
# Minor weighting to 100% or freeze
metric -= 0.001
# Round metric to 4 DP
metric = self.dp4(metric)
if self.debug_enable:
self.log(
"Sim: SOC {} soc_min {} @ {} window {} metric {} cost {} cost10 {} soc {} soc10 {} final_iboost {} final_iboost10 {} final_carbon_g {} metric_keep {} cycle {} carbon {} import {} export {}".format(
try_soc,
self.dp4(soc_min),
self.time_abs_str(soc_min_minute),
window_n,
metric,
cost,
cost10,
soc,
soc10,
final_iboost,
final_iboost10,
final_carbon_g,
metric_keep,
battery_cycle,
final_carbon_g,
import_kwh_battery + import_kwh_house,
export_kwh,
)
)
window_results[try_soc] = metric
# Only select the lower SOC if it makes a notable improvement has defined by min_improvement (divided in M windows)
# and it doesn't fall below the soc_keep threshold
if (metric + min_improvement_scaled) <= best_metric:
best_metric = metric
best_soc = try_soc
best_cost = cost
best_soc_min = soc_min
best_soc_min_minute = soc_min_minute
best_keep = metric_keep
best_cycle = battery_cycle
best_carbon = final_carbon_g
best_import = import_kwh_battery + import_kwh_house
# Add margin last
best_soc = min(best_soc + self.best_soc_margin, self.soc_max)
if self.debug_enable:
if not all_n:
self.log(
"Try optimising charge window(s) {}: {} - {} price {} cost {} metric {} keep {} cycle {} carbon {} import {} export {} selected {} was {} results {}".format(
window_n,
self.time_abs_str(window["start"]),
self.time_abs_str(window["end"]),
charge_window[window_n]["average"],
self.dp4(best_cost),
self.dp4(best_metric),
self.dp4(best_keep),
self.dp4(best_cycle),
self.dp0(best_carbon),
self.dp4(import_kwh_battery + import_kwh_house),
self.dp4(export_kwh),
best_soc,
charge_limit[window_n],
window_results,
)
)
else:
self.log(
"Try optimising charge window(s) {}: price {} cost {} metric {} keep {} cycle {} carbon {} import {} selected {} was {} results {}".format(
all_n,
charge_window[window_n]["average"],
self.dp2(best_cost),
self.dp2(best_metric),
self.dp2(best_keep),
self.dp2(best_cycle),
self.dp0(best_carbon),
self.dp0(best_import),
best_soc,
charge_limit[window_n],
window_results,
)
)
return best_soc, best_metric, best_cost, best_soc_min, best_soc_min_minute, best_keep, best_cycle, best_carbon, best_import
def optimise_discharge(
self, window_n, record_charge_windows, try_charge_limit, charge_window, discharge_window, discharge_limit, all_n=None, end_record=None, freeze_only=False
):
"""
Optimise a single discharging window for best discharge %
"""
best_discharge = False
best_metric = 9999999
off_metric = 9999999
best_cost = 0
best_soc_min = 0
best_soc_min_minute = 0
best_keep = 0
best_cycle = 0
best_import = 0
best_carbon = 0
this_discharge_limit = 100.0
window = discharge_window[window_n]
try_discharge_window = copy.deepcopy(discharge_window)
try_discharge = copy.deepcopy(discharge_limit)
best_start = window["start"]
best_size = window["end"] - best_start
discharge_step = 5
# loop on each discharge option
if self.set_discharge_freeze and freeze_only:
loop_options = [100, 99]
elif self.set_discharge_freeze and not self.set_discharge_freeze_only:
# If we support freeze, try a 99% option which will freeze at any SOC level below this
loop_options = [100, 99, 0]
else:
loop_options = [100, 0]
# Collect all options
results = []
results10 = []
try_options = []
for loop_limit in loop_options:
# Loop on window size
loop_start = window["end"] - 10 # Minimum discharge window size 10 minutes
while loop_start >= window["start"]:
this_discharge_limit = loop_limit
start = loop_start
# Move the loop start back to full size
loop_start -= discharge_step
# Can't optimise all window start slot
if all_n and (start != window["start"]):
continue
# Don't optimise start of disabled windows or freeze only windows, just for discharge ones
if (this_discharge_limit in [100.0, 99.0]) and (start != window["start"]):
continue
# Never go below the minimum level
this_discharge_limit = max(calc_percent_limit(max(self.best_soc_min, self.reserve), self.soc_max), this_discharge_limit)
try_options.append([start, this_discharge_limit])
results.append(
self.launch_run_prediction_discharge(
this_discharge_limit, start, window_n, try_charge_limit, charge_window, try_discharge_window, try_discharge, False, all_n, end_record
)
)
results10.append(
self.launch_run_prediction_discharge(
this_discharge_limit, start, window_n, try_charge_limit, charge_window, try_discharge_window, try_discharge, True, all_n, end_record
)
)
# Get results from sims
try_results = []
for try_option in try_options:
hanres = results.pop(0)
hanres10 = results10.pop(0)
result = hanres.get()
result10 = hanres10.get()
try_results.append(try_option + [result, result10])
window_results = {}
for try_option in try_results:
start, this_discharge_limit, hanres, hanres10 = try_option
# Simulate with medium PV
cost, import_kwh_battery, import_kwh_house, export_kwh, soc_min, soc, soc_min_minute, battery_cycle, metric_keep, final_iboost, final_carbon_g = hanres
(
cost10,
import_kwh_battery10,
import_kwh_house10,
export_kwh10,
soc_min10,
soc10,
soc_min_minute10,
battery_cycle10,
metric_keep10,
final_iboost10,
final_carbon_g10,
) = hanres10
# Compute the metric from simulation results
metric = self.compute_metric(
end_record, soc, soc10, cost, cost10, final_iboost, final_iboost10, battery_cycle, metric_keep, final_carbon_g, import_kwh_battery, import_kwh_house, export_kwh
)
# Adjust to try to keep existing windows
if window_n < 2 and this_discharge_limit < 99.0 and self.discharge_window and self.isDischarging:
pwindow = discharge_window[window_n]
dwindow = self.discharge_window[0]
if (
self.minutes_now >= pwindow["start"]
and self.minutes_now < pwindow["end"]
and ((self.minutes_now >= dwindow["start"] and self.minutes_now < dwindow["end"]) or (dwindow["end"] == pwindow["start"]))
):
metric -= max(0.5, self.metric_min_improvement_discharge)
# Round metric to 4 DP
metric = self.dp4(metric)
if self.debug_enable:
self.log(
"Sim: Discharge {} window {} start {} end {}, import {} export {} min_soc {} @ {} soc {} soc10 {} cost {} cost10 {} metric {} cycle {} iboost {} iboost10 {} carbon {} keep {} end_record {}".format(
this_discharge_limit,
window_n,
self.time_abs_str(start),
self.time_abs_str(try_discharge_window[window_n]["end"]),
self.dp4(import_kwh_battery + import_kwh_house),
self.dp4(export_kwh),
self.dp4(soc_min),
self.time_abs_str(soc_min_minute),
self.dp4(soc),
self.dp4(soc10),
self.dp4(cost),
self.dp4(cost10),
self.dp4(metric),
self.dp4(battery_cycle * self.metric_battery_cycle),
self.dp4(final_iboost),
self.dp4(final_iboost10),
self.dp4(final_carbon_g),
self.dp4(metric_keep),
end_record,
)
)
window_size = try_discharge_window[window_n]["end"] - start
window_key = str(int(this_discharge_limit)) + "_" + str(window_size)
window_results[window_key] = metric
if all_n:
min_improvement_scaled = self.metric_min_improvement_discharge
else:
min_improvement_scaled = self.metric_min_improvement_discharge * window_size / 30.0
# Only select a discharge if it makes a notable improvement has defined by min_improvement (divided in M windows)
if ((metric + min_improvement_scaled) <= off_metric) and (metric <= best_metric):
best_metric = metric
best_discharge = this_discharge_limit
best_cost = cost
best_soc_min = soc_min
best_soc_min_minute = soc_min_minute
best_start = start
best_size = window_size
best_keep = metric_keep
best_cycle = battery_cycle
best_carbon = final_carbon_g
best_import = import_kwh_battery + import_kwh_house
# Store the metric for discharge off
if off_metric == 9999999:
off_metric = metric
if self.debug_enable:
if not all_n:
self.log(
"Try optimising discharge window(s) {}: {} - {} price {} cost {} metric {} carbon {} import {} keep {} selected {}% size {} was {}% results {}".format(
window_n,
self.time_abs_str(window["start"]),
self.time_abs_str(window["end"]),
window["average"],
self.dp4(best_cost),
self.dp4(best_metric),
self.dp4(best_carbon),
self.dp4(best_import),
self.dp4(best_keep),
best_discharge,
best_size,
discharge_limit[window_n],
window_results,
)
)
else:
self.log(
"Try optimising discharge window(s) {} price {} selected {}% size {} cost {} metric {} carbon {} import {} keep {} results {}".format(
all_n,
window["average"],
self.dp4(best_cost),
self.dp4(best_metric),
self.dp4(best_carbon),
self.dp4(best_import),
self.dp4(best_keep),
best_discharge,
best_size,
window_results,
)
)
return best_discharge, best_start, best_metric, best_cost, best_soc_min, best_soc_min_minute, best_keep, best_cycle, best_carbon, best_import
def window_sort_func(self, window):
"""
Helper sort index function
"""
return float(window["key"])
def window_sort_func_start(self, window):
"""
Helper sort index function
"""
return float(window["start"])
def sort_window_by_time(self, windows):
"""
Sort windows in start time order, return a new list of windows
"""
window_sorted = copy.deepcopy(windows)
window_sorted.sort(key=self.window_sort_func_start)
return window_sorted
def sort_window_by_price_combined(self, charge_windows, discharge_windows, stand_alone=False, secondary_order=False):
"""
Sort windows into price sets
"""
window_sort = []
window_links = {}
price_set = []
price_links = {}
# Add charge windows
if self.calculate_best_charge or stand_alone:
id = 0
for window in charge_windows:
# Account for losses in average rate as it makes import higher
if stand_alone:
average = self.dp2(window["average"])
else:
average = self.dp2(window["average"] / self.inverter_loss / self.battery_loss + self.metric_battery_cycle)
if self.carbon_enable:
carbon_intensity = self.carbon_intensity.get(window["start"] - self.minutes_now, 0)
average += carbon_intensity * self.carbon_metric / 1000.0
average += self.metric_self_sufficiency
if secondary_order:
average_export = self.dp2((self.rate_export.get(window["start"], 0) + self.rate_export.get(window["end"] - PREDICT_STEP, 0)) / 2)
else:
average_export = 0
sort_key = "%04.2f_%04.2f_%03d_c%02d" % (5000 - average, 5000 - average_export, 999 - id, id)
window_sort.append(sort_key)
window_links[sort_key] = {}
window_links[sort_key]["type"] = "c"
window_links[sort_key]["id"] = id
window_links[sort_key]["average"] = self.dp1(average) # Round to nearest 0.1 penny to avoid too many bands
window_links[sort_key]["average_secondary"] = self.dp1(average_export) # Round to nearest 0.1 penny to avoid too many bands
id += 1
# Add discharge windows
if self.calculate_best_discharge and not stand_alone:
id = 0
for window in discharge_windows:
# Account for losses in average rate as it makes export value lower
average = self.dp2(window["average"] * self.inverter_loss * self.battery_loss_discharge - self.metric_battery_cycle)
if self.carbon_enable:
carbon_intensity = self.carbon_intensity.get(window["start"] - self.minutes_now, 0)
average += carbon_intensity * self.carbon_metric / 1000.0
if secondary_order:
average_import = self.dp2((self.rate_import.get(window["start"], 0) + self.rate_import.get(window["end"] - PREDICT_STEP, 0)) / 2)
else:
average_import = 0
sort_key = "%04.2f_%04.2f_%03d_d%02d" % (5000 - average, 5000 - average_import, 999 - id, id)
if not self.calculate_discharge_first:
# Push discharge last if first is not set
sort_key = "zz_" + sort_key
window_sort.append(sort_key)
window_links[sort_key] = {}
window_links[sort_key]["type"] = "d"
window_links[sort_key]["id"] = id
window_links[sort_key]["average"] = self.dp1(average) # Round to nearest 0.1 penny to avoid too many bands
window_links[sort_key]["average_secondary"] = self.dp1(average_import) # Round to nearest 0.1 penny to avoid too many bands
id += 1
if window_sort:
window_sort.sort()
# Create price ordered links by set
for key in window_sort:
average = window_links[key]["average"]
if average not in price_set:
price_set.append(average)
price_links[average] = []
price_links[average].append(key)
return window_sort, window_links, price_set, price_links
def sort_window_by_time_combined(self, charge_windows, discharge_windows):
window_sort = []
window_links = {}
# Add charge windows
if self.calculate_best_charge:
id = 0
for window in charge_windows:
sort_key = "%04d_%03d_c" % (window["start"], id)
window_sort.append(sort_key)
window_links[sort_key] = {}
window_links[sort_key]["type"] = "c"
window_links[sort_key]["id"] = id
id += 1
# Add discharge windows
if self.calculate_best_discharge:
id = 0
for window in discharge_windows:
sort_key = "%04d_%03d_d" % (window["start"], id)
window_sort.append(sort_key)
window_links[sort_key] = {}
window_links[sort_key]["type"] = "d"
window_links[sort_key]["id"] = id
id += 1
if window_sort:
window_sort.sort()
return window_sort, window_links
def sort_window_by_price(self, windows, reverse_time=False):
"""
Sort the charge windows by highest price first, return a list of window IDs
"""
window_with_id = copy.deepcopy(windows)
wid = 0
for window in window_with_id:
window["id"] = wid
if reverse_time:
window["key"] = "%04.2f%02d" % (5000 - window["average"], 999 - window["id"])
else:
window["key"] = "%04.2f%02d" % (5000 - window["average"], window["id"])
wid += 1
window_with_id.sort(key=self.window_sort_func)
id_list = []
for window in window_with_id:
id_list.append(window["id"])
return id_list
def discard_unused_charge_slots(self, charge_limit_best, charge_window_best, reserve):
"""
Filter out unused charge slots (those set at 0)
"""
new_limit_best = []
new_window_best = []
max_slots = len(charge_limit_best)
for window_n in range(max_slots):
# Only keep slots > than reserve, or keep the last one so we don't have zero slots
# Also keep a slot if we are already inside it and charging is enabled
window = charge_window_best[window_n].copy()
start = window["start"]
end = window["end"]
limit = charge_limit_best[window_n]
if (
new_window_best
and (start == new_window_best[-1]["end"])
and (limit == new_limit_best[-1])
and (start not in self.manual_all_times)
and (new_window_best[-1]["start"] not in self.manual_all_times)
):
new_window_best[-1]["end"] = end
if self.debug_enable:
self.log(
"Combine charge slot {} with previous - target soc {} kWh slot {} start {} end {} limit {}".format(
window_n, new_limit_best[-1], new_window_best[-1], start, end, limit
)
)
elif (
(limit > 0 and self.set_charge_freeze)
or (limit > self.reserve and (not self.set_charge_freeze))
or (self.minutes_now >= start and self.minutes_now < end and self.charge_window and self.charge_window[0]["end"] == end)
):
new_limit_best.append(limit)
new_window_best.append(window)
else:
if self.debug_enable:
self.log("Clip off charge slot {} limit {}".format(window, limit))
return new_limit_best, new_window_best
def find_spare_energy(self, predict_soc, predict_export, step, first_charge):
"""
Find spare energy and set triggers
"""
triggers = self.args.get("export_triggers", [])
if not isinstance(triggers, list):
return
# Only run if we have export data
if not predict_export:
return
# Check each trigger
for trigger in triggers:
total_energy = 0
name = trigger.get("name", "trigger")
minutes = trigger.get("minutes", 60.0)
minutes = min(max(minutes, 0), first_charge)
energy = trigger.get("energy", 1.0)
try:
energy = float(energy)
except (ValueError, TypeError):
energy = 0.0
self.log("Warn: Bad energy value {} provided via trigger {}".format(energy, name))
self.record_status("Error: Bad energy value {} provided via trigger {}".format(energy, name), had_errors=True)
for minute in range(0, minutes, step):
total_energy += predict_export[minute]
sensor_name = "binary_sensor." + self.prefix + "_export_trigger_" + name
if total_energy >= energy:
state = "on"
else:
state = "off"
self.log("Evaluate trigger {} results {} total_energy {}".format(trigger, state, self.dp2(total_energy)))
self.dashboard_item(
sensor_name,
state=state,
attributes={
"friendly_name": "Predbat export trigger " + name,
"required": energy,
"available": self.dp2(total_energy),
"minutes": minutes,
"icon": "mdi:clock-start",
},
)
def set_charge_discharge_status(self, isCharging, isDischarging):
"""
Reports status on charging/discharging to binary sensor
"""
self.dashboard_item(
"binary_sensor." + self.prefix + "_charging", state="on" if isCharging else "off", attributes={"friendly_name": "Predbat is charging", "icon": "mdi:battery-arrow-up"}
)
self.dashboard_item(
"binary_sensor." + self.prefix + "_discharging",
state="on" if isDischarging else "off",
attributes={"friendly_name": "Predbat is discharging", "icon": "mdi:battery-arrow-down"},
)
def clip_charge_slots(self, minutes_now, predict_soc, charge_window_best, charge_limit_best, record_charge_windows, step):
"""
Clip charge slots that are useless as they don't charge at all
"""
for window_n in range(min(record_charge_windows, len(charge_window_best))):
window = charge_window_best[window_n]
limit = charge_limit_best[window_n]
limit_soc = self.soc_max * limit / 100.0
window_start = max(window["start"], minutes_now)
window_end = max(window["end"], minutes_now)
window_length = window_end - window_start
if limit <= 0.0:
# Ignore disabled windows
pass
elif window_length > 0:
predict_minute_start = max(int((window_start - minutes_now) / 5) * 5, 0)
predict_minute_end = int((window_end - minutes_now) / 5) * 5
if (predict_minute_start in predict_soc) and (predict_minute_end in predict_soc):
soc_start = predict_soc[predict_minute_start]
soc_end = predict_soc[predict_minute_end]
soc_min = min(soc_start, soc_end)
soc_min_percent = calc_percent_limit(soc_min, self.soc_max)
soc_max = max(soc_start, soc_end)
if self.debug_enable:
self.log(
"Examine charge window {} from {} - {} (minute {}) limit {} - starting soc {} ending soc {}".format(
window_n, window_start, window_end, predict_minute_start, limit, soc_start, soc_end
)
)
if (soc_min_percent > calc_percent_limit(charge_limit_best[window_n], self.soc_max)) and (charge_limit_best[window_n] != self.reserve):
charge_limit_best[window_n] = self.best_soc_min
self.log(
"Clip off charge window {} from {} - {} from limit {} to new limit {}".format(window_n, window_start, window_end, limit, charge_limit_best[window_n])
)
elif soc_max < charge_limit_best[window_n]:
# Clip down charge window to what can be achieved in the time
limit_soc = min(self.soc_max, soc_max + 10 * self.battery_rate_max_charge_scaled, charge_limit_best[window_n])
if self.best_soc_max > 0:
limit_soc = min(limit_soc, self.best_soc_max)
new_limit = max(limit_soc, self.best_soc_min)
if new_limit != charge_limit_best[window_n]:
charge_limit_best[window_n] = new_limit
if self.debug_enable:
self.log(
"Clip down charge window {} from {} - {} from limit {} to new limit {}".format(
window_n, window_start, window_end, limit, charge_limit_best[window_n]
)
)
else:
self.log("Warn: Clip charge window {} as it's already passed".format(window_n))
charge_limit_best[window_n] = self.best_soc_min
return charge_window_best, charge_limit_best
def clip_discharge_slots(self, minutes_now, predict_soc, discharge_window_best, discharge_limits_best, record_discharge_windows, step):
"""
Clip discharge slots to the right length
"""
for window_n in range(min(record_discharge_windows, len(discharge_window_best))):
window = discharge_window_best[window_n]
limit = discharge_limits_best[window_n]
limit_soc = self.soc_max * limit / 100.0
window_start = max(window["start"], minutes_now)
window_end = max(window["end"], minutes_now)
window_length = window_end - window_start
if limit == 100 or limit == 99:
# Ignore disabled windows & discharge freeze slots
pass
elif window_length > 0:
predict_minute_start = max(int((window_start - minutes_now) / 5) * 5, 0)
predict_minute_end = int((window_end - minutes_now) / 5) * 5
if (predict_minute_start in predict_soc) and (predict_minute_end in predict_soc):
soc_start = predict_soc[predict_minute_start]
soc_end = predict_soc[predict_minute_end]
soc_min = min(soc_start, soc_end)
soc_max = max(soc_start, soc_end)
if self.debug_enable:
self.log(
"Examine window {} from {} - {} (minute {}) limit {} - starting soc {} ending soc {}".format(
window_n, window_start, window_end, predict_minute_start, limit, soc_start, soc_end
)
)
# Discharge level adjustments for safety
if soc_min > limit_soc:
# Give it 10 minute margin
limit_soc = max(limit_soc, soc_min - 10 * self.battery_rate_max_discharge_scaled)
discharge_limits_best[window_n] = calc_percent_limit(limit_soc, self.soc_max)
if limit != discharge_limits_best[window_n] and self.debug_enable:
self.log(
"Clip up discharge window {} from {} - {} from limit {} to new limit {}".format(
window_n, window_start, window_end, limit, discharge_limits_best[window_n]
)
)
elif soc_max < limit_soc:
# Bring down limit to match predicted soc for freeze only mode
if self.set_discharge_freeze:
# Get it 5 minute margin upwards
limit_soc = min(limit_soc, soc_max + 5 * self.battery_rate_max_discharge_scaled)
discharge_limits_best[window_n] = calc_percent_limit(limit_soc, self.soc_max)
if limit != discharge_limits_best[window_n] and self.debug_enable:
self.log(
"Clip down discharge window {} from {} - {} from limit {} to new limit {}".format(
window_n, window_start, window_end, limit, discharge_limits_best[window_n]
)
)
else:
self.log("Warn: Clip discharge window {} as it's already passed".format(window_n))
discharge_limits_best[window_n] = 100
return discharge_window_best, discharge_limits_best
def discard_unused_discharge_slots(self, discharge_limits_best, discharge_window_best):
"""
Filter out the windows we disabled
"""
new_best = []
new_enable = []
for window_n in range(len(discharge_limits_best)):
if discharge_limits_best[window_n] < 100.0:
# Also merge contiguous enabled windows
if (
new_best
and (discharge_window_best[window_n]["start"] == new_best[-1]["end"])
and (discharge_limits_best[window_n] == new_enable[-1])
and (discharge_window_best[window_n]["start"] not in self.manual_all_times)
and (new_best[-1]["start"] not in self.manual_all_times)
):
new_best[-1]["end"] = discharge_window_best[window_n]["end"]
if self.debug_enable:
self.log("Combine discharge slot {} with previous - percent {} slot {}".format(window_n, new_enable[-1], new_best[-1]))
else:
new_best.append(copy.deepcopy(discharge_window_best[window_n]))
new_enable.append(discharge_limits_best[window_n])
return new_enable, new_best
def tweak_plan(self, end_record, best_metric, metric_keep):
"""
Tweak existing plan only
"""
record_charge_windows = max(self.max_charge_windows(end_record + self.minutes_now, self.charge_window_best), 1)
record_discharge_windows = max(self.max_charge_windows(end_record + self.minutes_now, self.discharge_window_best), 1)
self.log("Tweak optimisation started")
best_soc = self.soc_max
best_cost = best_metric
best_keep = metric_keep
best_cycle = 0
best_carbon = 0
best_import = 0
count = 0
window_sorted, window_index = self.sort_window_by_time_combined(self.charge_window_best[:record_charge_windows], self.discharge_window_best[:record_discharge_windows])
for key in window_sorted:
typ = window_index[key]["type"]
window_n = window_index[key]["id"]
if typ == "c":
window_start = self.charge_window_best[window_n]["start"]
if self.calculate_best_charge and (window_start not in self.manual_all_times):
best_soc, best_metric, best_cost, soc_min, soc_min_minute, best_keep, best_cycle, best_carbon, best_import = self.optimise_charge_limit(
window_n,
record_charge_windows,
self.charge_limit_best,
self.charge_window_best,
self.discharge_window_best,
self.discharge_limits_best,
end_record=end_record,
)
self.charge_limit_best[window_n] = best_soc
else:
window_start = self.discharge_window_best[window_n]["start"]
if self.calculate_best_discharge and (window_start not in self.manual_all_times):
if not self.calculate_discharge_oncharge:
hit_charge = self.hit_charge_window(self.charge_window_best, self.discharge_window_best[window_n]["start"], self.discharge_window_best[window_n]["end"])
if hit_charge >= 0 and self.charge_limit_best[hit_charge] > 0.0:
continue
if (
not self.iboost_on_discharge
and self.iboost_enable
and self.iboost_plan
and (self.hit_charge_window(self.iboost_plan, self.discharge_window_best[window_n]["start"], self.discharge_window_best[window_n]["end"]) >= 0)
):
continue
if not self.car_charging_from_battery and self.hit_car_window(self.discharge_window_best[window_n]["start"], self.discharge_window_best[window_n]["end"]):
continue
best_soc, best_start, best_metric, best_cost, soc_min, soc_min_minute, best_keep, best_cycle, best_carbon, best_import = self.optimise_discharge(
window_n,
record_discharge_windows,
self.charge_limit_best,
self.charge_window_best,
self.discharge_window_best,
self.discharge_limits_best,
end_record=end_record,
)
self.discharge_limits_best[window_n] = best_soc
self.discharge_window_best[window_n]["start"] = best_start
count += 1
if count >= 8:
break
self.log(
"Tweak optimisation finished metric {} cost {} metric_keep {} cycle {} carbon {} import {}".format(
self.dp2(best_metric), self.dp2(best_cost), self.dp2(best_keep), self.dp2(best_cycle), self.dp0(best_carbon), self.dp2(best_import)
)
)
def optimise_all_windows(self, best_metric, metric_keep):
"""
Optimise all windows, both charge and discharge in rate order
"""
record_charge_windows = max(self.max_charge_windows(self.end_record + self.minutes_now, self.charge_window_best), 1)
record_discharge_windows = max(self.max_charge_windows(self.end_record + self.minutes_now, self.discharge_window_best), 1)
window_sorted, window_index, price_set, price_links = self.sort_window_by_price_combined(
self.charge_window_best[:record_charge_windows], self.discharge_window_best[:record_discharge_windows]
)
best_soc = self.soc_max
best_cost = best_metric
best_keep = metric_keep
best_cycle = 0
best_carbon = 0
best_import = 0
best_price = 0
best_price_discharge = 0
fast_mode = True
# Optimise all windows by picking a price threshold default
if price_set and self.calculate_best_charge and self.charge_window_best:
self.log("Optimise all windows, total charge {} discharge {}".format(record_charge_windows, record_discharge_windows))
self.optimise_charge_windows_reset(reset_all=True)
self.optimise_charge_windows_manual()
(
self.charge_limit_best,
ignore_discharge_limits,
best_price,
best_price_discharge,
best_metric,
best_cost,
best_keep,
best_soc_min,
best_cycle,
best_carbon,
best_import,
tried_list,
) = self.optimise_charge_limit_price_threads(
price_set,
price_links,
window_index,
record_charge_windows,
record_discharge_windows,
self.charge_limit_best,
self.charge_window_best,
self.discharge_window_best,
self.discharge_limits_best,
end_record=self.end_record,
fast=fast_mode,
quiet=True,
)
if self.calculate_regions:
self.end_record = self.record_length(self.charge_window_best, self.charge_limit_best, best_price)
region_size = int(16 * 60)
while region_size >= 2 * 60:
self.log(">> Region optimisation pass width {}".format(region_size))
for region in range(0, self.end_record + self.minutes_now, region_size):
region_end = min(region + region_size, self.end_record + self.minutes_now)
if region_end < self.minutes_now:
continue
(
self.charge_limit_best,
ignore_discharge_limits,
ignore_best_price,
ignore_best_price_discharge,
best_metric,
best_cost,
best_keep,
best_soc_min,
best_cycle,
best_carbon,
best_import,
tried_list,
) = self.optimise_charge_limit_price_threads(
price_set,
price_links,
window_index,
record_charge_windows,
record_discharge_windows,
self.charge_limit_best,
self.charge_window_best,
self.discharge_window_best,
ignore_discharge_limits,
end_record=self.end_record,
region_start=region,
region_end=region_end,
fast=fast_mode,
quiet=True,
best_metric=best_metric,
best_cost=best_cost,
best_keep=best_keep,
best_soc_min=best_soc_min,
best_price_charge=best_price,
best_price_discharge=best_price_discharge,
best_cycle=best_cycle,
best_import=best_import,
best_carbon=best_carbon,
tried_list=tried_list,
)
region_size = int(region_size / 2)
# Set the new end record and blackout period based on the levelling
self.end_record = self.record_length(self.charge_window_best, self.charge_limit_best, best_price)
self.optimise_charge_windows_reset(reset_all=False)
self.optimise_charge_windows_manual()
record_charge_windows = max(self.max_charge_windows(self.end_record + self.minutes_now, self.charge_window_best), 1)
record_discharge_windows = max(self.max_charge_windows(self.end_record + self.minutes_now, self.discharge_window_best), 1)
window_sorted, window_index, price_set, price_links = self.sort_window_by_price_combined(
self.charge_window_best[:record_charge_windows], self.discharge_window_best[:record_discharge_windows], secondary_order=self.calculate_secondary_order
)
self.rate_best_cost_threshold_charge = best_price
self.rate_best_cost_threshold_discharge = best_price_discharge
# Work out the lowest rate we charge at from the first pass
lowest_price_charge = best_price
for price_key in price_set:
links = price_links[price_key]
for key in links:
typ = window_index[key]["type"]
window_n = window_index[key]["id"]
price = window_index[key]["average"]
if typ == "c" and (self.charge_limit_best[window_n] > self.reserve and price < lowest_price_charge):
lowest_price_charge = price
# Optimise individual windows in the price band for charge/discharge
# First optimise those at or below threshold highest to lowest (to turn down values)
# then optimise those above the threshold lowest to highest (to turn up values)
# Do the opposite for discharge.
self.log(
"Starting second optimisation end_record {} best_price {} best_price_discharge {} lowest_price_charge {} with charge limits {}".format(
self.time_abs_str(self.end_record + self.minutes_now), best_price, best_price_discharge, lowest_price_charge, self.charge_limit_best
)
)
for pass_type in ["freeze", "normal", "low"]:
start_at_low = False
if pass_type in ["low"]:
price_set.reverse()
start_at_low = True
for price_key in price_set:
links = price_links[price_key].copy()
# Freeze pass should be done in time order (newest first)
if pass_type in ["freeze"]:
links.reverse()
printed_set = False
for key in links:
typ = window_index[key]["type"]
window_n = window_index[key]["id"]
price = window_index[key]["average"]
if typ == "c":
# Store price set with window
self.charge_window_best[window_n]["set"] = price
window_start = self.charge_window_best[window_n]["start"]
# Freeze pass is just discharge freeze
if pass_type in ["freeze"]:
continue
# For start at high only tune down excess high slots
if (not start_at_low) and (price > best_price) and (self.charge_limit_best[window_n] != self.soc_max):
if self.debug_enable:
self.log("Skip start at high window {} best limit {}".format(window_n, (self.charge_limit_best[window_n])))
continue
if self.calculate_best_charge and (window_start not in self.manual_all_times):
if not printed_set:
self.log(
"Optimise price set {} pass {} price {} start_at_low {} best_price {} best_metric {} best_cost {} best_cycle {} best_carbon {} best_import {}".format(
price_key,
pass_type,
price,
start_at_low,
best_price,
self.dp2(best_metric),
self.dp2(best_cost),
self.dp2(best_cycle),
self.dp0(best_carbon),
self.dp2(best_import),
)
)
printed_set = True
average = self.charge_window_best[window_n]["average"]
best_soc, best_metric, best_cost, soc_min, soc_min_minute, best_keep, best_cycle, best_carbon, best_import = self.optimise_charge_limit(
window_n,
record_charge_windows,
self.charge_limit_best,
self.charge_window_best,
self.discharge_window_best,
self.discharge_limits_best,
end_record=self.end_record,
)
self.charge_limit_best[window_n] = best_soc
if self.debug_enable:
self.log(
"Best charge limit window {} time {} - {} cost {} charge_limit {} (adjusted) min {} @ {} (margin added {} and min {} max {}) with metric {} cost {} cycle {} carbon {} import {} windows {}".format(
window_n,
self.time_abs_str(self.charge_window_best[window_n]["start"]),
self.time_abs_str(self.charge_window_best[window_n]["end"]),
average,
self.dp2(best_soc),
self.dp2(soc_min),
self.time_abs_str(soc_min_minute),
self.best_soc_margin,
self.best_soc_min,
self.best_soc_max,
self.dp2(best_metric),
self.dp2(best_cost),
self.dp2(best_cycle),
self.dp0(best_carbon),
self.dp2(best_import),
calc_percent_limit(self.charge_limit_best, self.soc_max),
)
)
else:
# Store price set with window
self.discharge_window_best[window_n]["set"] = price
window_start = self.discharge_window_best[window_n]["start"]
# Ignore freeze pass if discharge freeze disabled
if not self.set_discharge_freeze and pass_type == "freeze":
continue
# Do highest price first
# Second pass to tune down any excess exports only
if pass_type == "low" and ((price > best_price) or (self.discharge_limits_best[window_n] == 100.0)):
continue
if self.calculate_best_discharge and (window_start not in self.manual_all_times):
if not self.calculate_discharge_oncharge:
hit_charge = self.hit_charge_window(
self.charge_window_best, self.discharge_window_best[window_n]["start"], self.discharge_window_best[window_n]["end"]
)
if hit_charge >= 0 and self.charge_limit_best[hit_charge] > 0.0:
continue
if not self.car_charging_from_battery and self.hit_car_window(
self.discharge_window_best[window_n]["start"], self.discharge_window_best[window_n]["end"]
):
continue
if (
not self.iboost_on_discharge
and self.iboost_enable
and self.iboost_plan
and (self.hit_charge_window(self.iboost_plan, self.discharge_window_best[window_n]["start"], self.discharge_window_best[window_n]["end"]) >= 0)
):
continue
average = self.discharge_window_best[window_n]["average"]
if price < lowest_price_charge:
if self.debug_enable and 0:
self.log(
"Skipping discharge optimisation on rate {} as it is unlikely to be profitable (threshold {} real rate {})".format(
price, best_price, self.dp2(average)
)
)
continue
if not printed_set:
self.log(
"Optimise price set {} pass {} price {} start_at_low {} best_price {} best_metric {} best_cost {} best_cycle {} best_carbon {} best_import {}".format(
price_key,
pass_type,
price,
start_at_low,
best_price,
self.dp2(best_metric),
self.dp2(best_cost),
self.dp2(best_cycle),
self.dp0(best_carbon),
self.dp2(best_import),
)
)
printed_set = True
best_soc, best_start, best_metric, best_cost, soc_min, soc_min_minute, best_keep, best_cycle, best_carbon, best_import = self.optimise_discharge(
window_n,
record_discharge_windows,
self.charge_limit_best,
self.charge_window_best,
self.discharge_window_best,
self.discharge_limits_best,
end_record=self.end_record,
freeze_only=pass_type in ["freeze"],
)
self.discharge_limits_best[window_n] = best_soc
self.discharge_window_best[window_n]["start"] = best_start
if self.debug_enable:
self.log(
"Best discharge limit window {} time {} - {} cost {} discharge_limit {} (adjusted) min {} @ {} (margin added {} and min {}) with metric {} cost {} cycle {} carbon {} import {}".format(
window_n,
self.time_abs_str(self.discharge_window_best[window_n]["start"]),
self.time_abs_str(self.discharge_window_best[window_n]["end"]),
average,
best_soc,
self.dp2(soc_min),
self.time_abs_str(soc_min_minute),
self.best_soc_margin,
self.best_soc_min,
self.dp2(best_metric),
self.dp2(best_cost),
self.dp2(best_cycle),
self.dp0(best_carbon),
self.dp2(best_import),
)
)
# Log set of charge and discharge windows
if self.calculate_best_charge:
self.log(
"Best charge windows best_metric {} best_cost {} best_carbon {} best_import {} metric_keep {} end_record {} windows {}".format(
self.dp2(best_metric),
self.dp2(best_cost),
self.dp0(best_carbon),
self.dp2(best_import),
self.dp2(best_keep),
self.time_abs_str(self.end_record + self.minutes_now),
self.window_as_text(self.charge_window_best, calc_percent_limit(self.charge_limit_best, self.soc_max), ignore_min=True),
)
)
if self.calculate_best_discharge:
self.log(
"Best discharge windows best_metric {} best_cost {} best_carbon {} best_import {} metric_keep {} end_record {} windows {}".format(
self.dp2(best_metric),
self.dp2(best_cost),
self.dp0(best_carbon),
self.dp2(best_import),
self.dp2(best_keep),
self.time_abs_str(self.end_record + self.minutes_now),
self.window_as_text(self.discharge_window_best, self.discharge_limits_best, ignore_max=True),
)
)
# Re-compute end record
self.end_record = self.record_length(self.charge_window_best, self.charge_limit_best, best_price)
record_charge_windows = max(self.max_charge_windows(self.end_record + self.minutes_now, self.charge_window_best), 1)
record_discharge_windows = max(self.max_charge_windows(self.end_record + self.minutes_now, self.discharge_window_best), 1)
if self.calculate_second_pass:
self.log("Second pass optimisation started")
count = 0
window_sorted, window_index = self.sort_window_by_time_combined(self.charge_window_best[:record_charge_windows], self.discharge_window_best[:record_discharge_windows])
for key in window_sorted:
typ = window_index[key]["type"]
window_n = window_index[key]["id"]
if typ == "c":
window_start = self.charge_window_best[window_n]["start"]
if self.calculate_best_charge and (window_start not in self.manual_all_times):
best_soc, best_metric, best_cost, soc_min, soc_min_minute, best_keep, best_cycle, best_carbon, best_import = self.optimise_charge_limit(
window_n,
record_charge_windows,
self.charge_limit_best,
self.charge_window_best,
self.discharge_window_best,
self.discharge_limits_best,
end_record=self.end_record,
)
self.charge_limit_best[window_n] = best_soc
else:
window_start = self.discharge_window_best[window_n]["start"]
if self.calculate_best_discharge and (window_start not in self.manual_all_times):
if not self.calculate_discharge_oncharge:
hit_charge = self.hit_charge_window(self.charge_window_best, self.discharge_window_best[window_n]["start"], self.discharge_window_best[window_n]["end"])
if hit_charge >= 0 and self.charge_limit_best[hit_charge] > 0.0:
continue
if not self.car_charging_from_battery and self.hit_car_window(self.discharge_window_best[window_n]["start"], self.discharge_window_best[window_n]["end"]):
continue
if (
not self.iboost_on_discharge
and self.iboost_enable
and self.iboost_plan
and (self.hit_charge_window(self.iboost_plan, self.discharge_window_best[window_n]["start"], self.discharge_window_best[window_n]["end"]) >= 0)
):
continue
average = self.discharge_window_best[window_n]["average"]
best_soc, best_start, best_metric, best_cost, soc_min, soc_min_minute, best_keep, best_cycle, best_carbon, best_import = self.optimise_discharge(
window_n,
record_discharge_windows,
self.charge_limit_best,
self.charge_window_best,
self.discharge_window_best,
self.discharge_limits_best,
end_record=self.end_record,
)
self.discharge_limits_best[window_n] = best_soc
self.discharge_window_best[window_n]["start"] = best_start
if (count % 16) == 0:
self.log(
"Final optimisation type {} window {} metric {} metric_keep {} best_carbon {} best_import {} cost {}".format(
typ, window_n, best_metric, self.dp2(best_keep), self.dp0(best_carbon), self.dp2(best_import), self.dp2(best_cost)
)
)
count += 1
self.log(
"Second pass optimisation finished metric {} cost {} metric_keep {} cycle {} carbon {} import {}".format(
best_metric, self.dp2(best_cost), self.dp2(best_keep), self.dp2(best_cycle), self.dp0(best_carbon), self.dp2(best_carbon)
)
)
def optimise_charge_windows_manual(self):
"""
Manual window overrides
"""
if self.charge_window_best and self.calculate_best_charge:
for window_n in range(len(self.charge_window_best)):
if self.charge_window_best[window_n]["start"] in self.manual_idle_times:
self.charge_limit_best[window_n] = 0
elif self.charge_window_best[window_n]["start"] in self.manual_discharge_times:
self.charge_limit_best[window_n] = 0
elif self.charge_window_best[window_n]["start"] in self.manual_freeze_discharge_times:
self.charge_limit_best[window_n] = 0
elif self.charge_window_best[window_n]["start"] in self.manual_charge_times:
self.charge_limit_best[window_n] = self.soc_max
elif self.charge_window_best[window_n]["start"] in self.manual_freeze_charge_times:
self.charge_limit_best[window_n] = self.reserve
if self.discharge_window_best and self.calculate_best_discharge:
for window_n in range(len(self.discharge_window_best)):
if self.discharge_window_best[window_n]["start"] in self.manual_idle_times:
self.discharge_limits_best[window_n] = 100
elif self.discharge_window_best[window_n]["start"] in self.manual_discharge_times:
self.discharge_limits_best[window_n] = 0
elif self.discharge_window_best[window_n]["start"] in self.manual_freeze_discharge_times:
self.discharge_limits_best[window_n] = 99
def optimise_charge_windows_reset(self, reset_all):
"""
Reset the charge windows to min
Parameters:
- reset_all (bool): If True, reset all charge windows. If False, reset only the charge windows that are in the record window.
Returns:
None
"""
if self.charge_window_best and self.calculate_best_charge:
# Set all to max
for window_n in range(len(self.charge_window_best)):
if self.charge_window_best[window_n]["start"] < (self.minutes_now + self.end_record):
if reset_all:
self.charge_limit_best[window_n] = 0.0
else:
self.charge_limit_best[window_n] = self.soc_max
def window_as_text(self, windows, percents, ignore_min=False, ignore_max=False):
"""
Convert window in minutes to text string
"""
txt = "[ "
first_window = True
for window_n in range(len(windows)):
window = windows[window_n]
percent = percents[window_n]
average = window["average"]
if ignore_min and percent == 0.0:
continue
if ignore_max and percent == 100.0:
continue
if not first_window:
txt += ", "
first_window = False
start_timestamp = self.midnight_utc + timedelta(minutes=window["start"])
start_time = start_timestamp.strftime("%d-%m %H:%M:%S")
end_timestamp = self.midnight_utc + timedelta(minutes=window["end"])
end_time = end_timestamp.strftime("%d-%m %H:%M:%S")
txt += start_time + " - "
txt += end_time
txt += " @ {}{} {}%".format(self.dp2(average), self.currency_symbols[1], self.dp2(percent))
txt += " ]"
return txt
def get_car_charging_planned(self):
"""
Get the car attributes
"""
self.car_charging_planned = [False for c in range(self.num_cars)]
self.car_charging_now = [False for c in range(self.num_cars)]
self.car_charging_plan_smart = [False for c in range(self.num_cars)]
self.car_charging_plan_max_price = [False for c in range(self.num_cars)]
self.car_charging_plan_time = [False for c in range(self.num_cars)]
self.car_charging_battery_size = [100.0 for c in range(self.num_cars)]
self.car_charging_limit = [100.0 for c in range(self.num_cars)]
self.car_charging_rate = [7.4 for c in range(max(self.num_cars, 1))]
self.car_charging_slots = [[] for c in range(self.num_cars)]
self.car_charging_exclusive = [False for c in range(self.num_cars)]
self.car_charging_planned_response = self.get_arg("car_charging_planned_response", ["yes", "on", "enable", "true"])
self.car_charging_now_response = self.get_arg("car_charging_now_response", ["yes", "on", "enable", "true"])
self.car_charging_from_battery = self.get_arg("car_charging_from_battery")
# Car charging planned sensor
for car_n in range(self.num_cars):
# Get car N planned status
planned = self.get_arg("car_charging_planned", "no", index=car_n)
if isinstance(planned, str):
if planned.lower() in self.car_charging_planned_response:
planned = True
else:
planned = False
elif not isinstance(planned, bool):
planned = False
self.car_charging_planned[car_n] = planned
# Car is charging now sensor
charging_now = self.get_arg("car_charging_now", "no", index=car_n)
if isinstance(charging_now, str):
if charging_now.lower() in self.car_charging_now_response:
charging_now = True
else:
charging_now = False
elif not isinstance(charging_now, bool):
charging_now = False
self.car_charging_now[car_n] = charging_now
# Other car related configuration
self.car_charging_plan_smart[car_n] = self.get_arg("car_charging_plan_smart", False)
self.car_charging_plan_max_price[car_n] = self.get_arg("car_charging_plan_max_price", 0.0)
self.car_charging_plan_time[car_n] = self.get_arg("car_charging_plan_time", "07:00:00")
self.car_charging_battery_size[car_n] = float(self.get_arg("car_charging_battery_size", 100.0, index=car_n))
self.car_charging_rate[car_n] = float(self.get_arg("car_charging_rate"))
self.car_charging_limit[car_n] = self.dp3((float(self.get_arg("car_charging_limit", 100.0, index=car_n)) * self.car_charging_battery_size[car_n]) / 100.0)
self.car_charging_exclusive[car_n] = self.get_arg("car_charging_exclusive", False, index=car_n)
if self.num_cars > 0:
self.log(
"Cars {} charging from battery {} planned {}, charging_now {} smart {}, max_price {}, plan_time {}, battery size {}, limit {}, rate {}, exclusive {}".format(
self.num_cars,
self.car_charging_from_battery,
self.car_charging_planned,
self.car_charging_now,
self.car_charging_plan_smart,
self.car_charging_plan_max_price,
self.car_charging_plan_time,
self.car_charging_battery_size,
self.car_charging_limit,
self.car_charging_rate,
self.car_charging_exclusive,
)
)
def fetch_pv_datapoints(self, argname):
"""
Get some solcast data from argname argument
"""
data = []
total_data = 0
total_sensor = 0
if argname in self.args:
# Found out if detailedForecast is present or not, then set the attribute name
# in newer solcast plugins only forecast is used
attribute = "detailedForecast"
entity_id = self.get_arg(argname, None, indirect=False)
if entity_id:
result = self.get_state_wrapper(entity_id=entity_id, attribute=attribute)
if not result:
attribute = "forecast"
try:
data = self.get_state_wrapper(entity_id=self.get_arg(argname, indirect=False), attribute=attribute)
except (ValueError, TypeError):
self.log("Warn: Unable to fetch solar forecast data from sensor {} check your setting of {}".format(self.get_arg(argname, indirect=False), argname))
self.record_status("Error: {} not be set correctly, check apps.yaml", debug=self.get_arg(argname, indirect=False), had_errors=True)
# Solcast new vs old version
# check the total vs the sum of 30 minute slots and work out scale factor
if data:
for entry in data:
total_data += entry["pv_estimate"]
total_data = self.dp2(total_data)
total_sensor = self.dp2(self.get_arg(argname, 1.0))
return data, total_data, total_sensor
def fetch_extra_load_forecast(self, now_utc):
"""
Fetch extra load forecast, this is future load data
"""
load_forecast = {}
if "load_forecast" in self.args:
entity_ids = self.get_arg("load_forecast", indirect=False)
if isinstance(entity_ids, str):
entity_ids = [entity_ids]
for entity_id in entity_ids:
attribute = None
if "$" in entity_id:
entity_id, attribute = entity_id.split("$")
try:
self.log("Loading extra load forecast from {} attribute {}".format(entity_id, attribute))
data = self.get_state_wrapper(entity_id=entity_id, attribute=attribute)
except (ValueError, TypeError) as e:
self.log("Error: Unable to fetch load forecast data from sensor {} exception {}".format(entity_id, e))
data = None
# Convert format from dict to array
if isinstance(data, dict):
data_array = []
for key, value in data.items():
data_array.append({"energy": value, "last_updated": key})
data = data_array
# Load data
load_forecast = self.minute_data(
data,
self.forecast_days,
self.midnight_utc,
"energy",
"last_updated",
backwards=False,
clean_increment=False,
smoothing=True,
divide_by=1.0,
scale=self.load_scaling,
required_unit="kWh",
accumulate=load_forecast,
)
if load_forecast:
self.log(
"Loaded load forecast from {} load from midnight {} to now {} to midnight {}".format(
entity_id, load_forecast.get(0, 0), load_forecast.get(self.minutes_now, 0), load_forecast.get(24 * 60, 0)
)
)
else:
self.log("Warn: Unable to load load forecast from {}".format(entity_id))
return load_forecast
def publish_pv_stats(self, pv_forecast_data, divide_by, period):
"""
Publish some PV stats
"""
total_left_today = 0
total_left_today10 = 0
total_left_today90 = 0
forecast_day = {}
total_day = {}
total_day10 = {}
total_day90 = {}
days = 0
midnight_today = self.midnight_utc
now = self.now_utc
power_scale = 60 / period # Scale kwh to power
for entry in pv_forecast_data:
if "period_start" not in entry:
continue
try:
this_point = datetime.strptime(entry["period_start"], TIME_FORMAT)
except (ValueError, TypeError):
continue
if this_point >= midnight_today:
day = (this_point - midnight_today).days
if day not in total_day:
total_day[day] = 0
total_day10[day] = 0
total_day90[day] = 0
forecast_day[day] = []
days = max(days, day + 1)
pv_estimate = entry.get("pv_estimate", 0)
pv_estimate10 = entry.get("pv_estimate10", pv_estimate)
pv_estimate90 = entry.get("pv_estimate90", pv_estimate)
pv_estimate /= divide_by
pv_estimate10 /= divide_by
pv_estimate90 /= divide_by
total_day[day] += pv_estimate
total_day10[day] += pv_estimate10
total_day90[day] += pv_estimate90
if day == 0 and this_point >= now:
total_left_today += pv_estimate
total_left_today10 += pv_estimate10
total_left_today90 += pv_estimate90
fentry = {
"period_start": entry["period_start"],
"pv_estimate": self.dp2(pv_estimate * power_scale),
"pv_estimate10": self.dp2(pv_estimate10 * power_scale),
"pv_estimate90": self.dp2(pv_estimate90 * power_scale),
}
forecast_day[day].append(fentry)
days = min(days, 7)
for day in range(days):
if day == 0:
self.log(
"PV Forecast for today is {} ({} 10% {} 90%) kWh and left today is {} ({} 10% {} 90%) kWh".format(
self.dp2(total_day[day]),
self.dp2(total_day10[day]),
self.dp2(total_day90[day]),
self.dp2(total_left_today),
self.dp2(total_left_today10),
self.dp2(total_left_today90),
)
)
self.dashboard_item(
"sensor." + self.prefix + "_pv_today",
state=self.dp2(total_day[day]),
attributes={
"friendly_name": "PV today",
"state_class": "measurement",
"unit_of_measurement": "kWh",
"icon": "mdi:solar-power",
"device_class": "energy",
"total": self.dp2(total_day[day]),
"total10": self.dp2(total_day10[day]),
"total90": self.dp2(total_day90[day]),
"remaining": self.dp2(total_left_today),
"remaining10": self.dp2(total_left_today10),
"remaining90": self.dp2(total_left_today90),
"detailedForecast": forecast_day[day],
"api_limit": self.solcast_api_limit,
"api_used": self.solcast_api_used,
},
)
else:
day_name = "tomorrow" if day == 1 else "d{}".format(day)
day_name_long = day_name if day == 1 else "day {}".format(day)
self.log("PV Forecast for day {} is {} ({} 10% {} 90%) kWh".format(day_name, self.dp2(total_day[day]), self.dp2(total_day10[day]), self.dp2(total_day90[day])))
self.dashboard_item(
"sensor." + self.prefix + "_pv_" + day_name,
state=self.dp2(total_day[day]),
attributes={
"friendly_name": "PV " + day_name_long,
"state_class": "measurement",
"unit_of_measurement": "kWh",
"icon": "mdi:solar-power",
"device_class": "energy",
"total": self.dp2(total_day[day]),
"total10": self.dp2(total_day10[day]),
"total90": self.dp2(total_day90[day]),
"detailedForecast": forecast_day[day],
},
)
def fetch_pv_forecast(self):
"""
Fetch the PV Forecast data from Solcast
either via HA or direct to their cloud
"""
pv_forecast_minute = {}
pv_forecast_minute10 = {}
pv_forecast_data = []
pv_forecast_total_data = 0
pv_forecast_total_sensor = 0
if "solcast_host" in self.args:
self.log("Obtaining solar forecast from Solcast API")
pv_forecast_data = self.download_solcast_data()
divide_by = 30.0
else:
self.log("Using Solcast integration from inside HA for solar forecast")
# Fetch data from each sensor
for argname in ["pv_forecast_today", "pv_forecast_tomorrow", "pv_forecast_d3", "pv_forecast_d4"]:
data, total_data, total_sensor = self.fetch_pv_datapoints(argname)
if data:
self.log("PV Data for {} total {} kWh".format(argname, total_sensor))
pv_forecast_data += data
pv_forecast_total_data += total_data
pv_forecast_total_sensor += total_sensor
# Work out data scale factor so it adds up (New Solcast is in kW but old was kWH)
factor = 1.0
if pv_forecast_total_data > 0.0 and pv_forecast_total_sensor > 0.0:
factor = round((pv_forecast_total_data / pv_forecast_total_sensor), 1)
# We want to divide the data into single minute slots
divide_by = self.dp2(30 * factor)
if factor != 1.0 and factor != 2.0:
self.log(
"Warn: PV Forecast data adds up to {} kWh but total sensors add up to {} kWh, this is unexpected and hence data maybe misleading (factor {})".format(
pv_forecast_total_data, pv_forecast_total_sensor, factor
)
)
if pv_forecast_data:
self.publish_pv_stats(pv_forecast_data, divide_by / 30.0, 30)
pv_estimate = self.get_arg("pv_estimate", default="")
if pv_estimate is None:
pv_estimate = "pv_estimate"
else:
pv_estimate = "pv_estimate" + str(pv_estimate)
pv_forecast_minute = self.minute_data(
pv_forecast_data,
self.forecast_days + 1,
self.midnight_utc,
pv_estimate,
"period_start",
backwards=False,
divide_by=divide_by,
scale=self.pv_scaling,
spreading=30,
)
pv_forecast_minute10 = self.minute_data(
pv_forecast_data,
self.forecast_days + 1,
self.midnight_utc,
"pv_estimate10",
"period_start",
backwards=False,
divide_by=divide_by,
scale=self.pv_scaling,
spreading=30,
)
else:
self.log("Warn: No solar data has been configured.")
return pv_forecast_minute, pv_forecast_minute10
def balance_inverters(self):
"""
Attempt to balance multiple inverters
"""
# Charge rate resets
balance_reset_charge = {}
balance_reset_discharge = {}
self.log(
"BALANCE: Enabled balance charge {} discharge {} crosscharge {} threshold charge {} discharge {}".format(
self.balance_inverters_charge,
self.balance_inverters_discharge,
self.balance_inverters_crosscharge,
self.balance_inverters_threshold_charge,
self.balance_inverters_threshold_discharge,
)
)
self.update_time(print=False)
# For each inverter get the details
num_inverters = int(self.get_arg("num_inverters", 1))
inverters = []
for id in range(num_inverters):
inverter = Inverter(self, id, quiet=True)
inverter.update_status(self.minutes_now, quiet=True)
if inverter.in_calibration:
self.log("Inverter {} is in calibration mode, not balancing".format(id))
return
inverters.append(inverter)
out_of_balance = False # Are all the SOC % the same
total_battery_power = 0 # Total battery power across inverters
total_max_rate = 0 # Total battery max rate across inverters
total_charge_rates = 0 # Current total charge rates
total_discharge_rates = 0 # Current total discharge rates
total_pv_power = 0 # Current total PV power
total_load_power = 0 # Current load power
socs = []
reserves = []
battery_powers = []
pv_powers = []
battery_max_rates = []
charge_rates = []
discharge_rates = []
load_powers = []
for inverter in inverters:
socs.append(inverter.soc_percent)
reserves.append(inverter.reserve_current)
if inverter.soc_percent != inverters[0].soc_percent:
out_of_balance = True
battery_powers.append(inverter.battery_power)
pv_powers.append(inverter.pv_power)
load_powers.append(inverter.load_power)
total_battery_power += inverter.battery_power
total_pv_power += inverter.pv_power
total_load_power += inverter.load_power
battery_max_rates.append(inverter.battery_rate_max_discharge * MINUTE_WATT)
total_max_rate += inverter.battery_rate_max_discharge * MINUTE_WATT
charge_rates.append(inverter.charge_rate_now * MINUTE_WATT)
total_charge_rates += inverter.charge_rate_now * MINUTE_WATT
discharge_rates.append(inverter.discharge_rate_now * MINUTE_WATT)
total_discharge_rates += inverter.discharge_rate_now * MINUTE_WATT
self.log(
"BALANCE: socs {} reserves {} battery_powers {} total {} battery_max_rates {} charge_rates {} pv_power {} load_power {} total {} discharge_rates {} total {}".format(
socs,
reserves,
battery_powers,
total_battery_power,
battery_max_rates,
charge_rates,
pv_powers,
load_powers,
total_charge_rates,
discharge_rates,
total_discharge_rates,
)
)
# Are we discharging
during_discharge = total_battery_power >= 0.0
during_charge = total_battery_power < 0.0
# Work out min and max socs
soc_min = min(socs)
soc_max = max(socs)
# Work out which inverters have low and high Soc
soc_low = []
soc_high = []
for inverter in inverters:
soc_low.append(inverter.soc_percent < soc_max and (abs(inverter.soc_percent - soc_max) >= self.balance_inverters_discharge))
soc_high.append(inverter.soc_percent > soc_min and (abs(inverter.soc_percent - soc_min) >= self.balance_inverters_charge))
above_reserve = [] # Is the battery above reserve?
below_full = [] # Is the battery below full?
can_power_house = [] # Could this inverter power the house alone?
can_store_pv = [] # Can store the PV for the house alone?
power_enough_discharge = [] # Inverter drawing enough power to be worth balancing
power_enough_charge = [] # Inverter drawing enough power to be worth balancing
for id in range(num_inverters):
above_reserve.append((socs[id] - reserves[id]) >= 4.0)
below_full.append(socs[id] < 100.0)
can_power_house.append((total_discharge_rates - discharge_rates[id] - 200) >= total_battery_power)
can_store_pv.append(total_pv_power <= (total_charge_rates - charge_rates[id]))
power_enough_discharge.append(battery_powers[id] >= 50.0)
power_enough_charge.append(inverters[id].battery_power <= -50.0)
self.log(
"BALANCE: out_of_balance {} above_reserve {} below_full {} can_power_house {} can_store_pv {} power_enough_discharge {} power_enough_charge {} soc_low {} soc_high {}".format(
out_of_balance, above_reserve, below_full, can_power_house, can_store_pv, power_enough_discharge, power_enough_charge, soc_low, soc_high
)
)
for this_inverter in range(num_inverters):
other_inverter = (this_inverter + 1) % num_inverters
if (
self.balance_inverters_discharge
and total_discharge_rates > 0
and out_of_balance
and during_discharge
and soc_low[this_inverter]
and above_reserve[other_inverter]
and can_power_house[this_inverter]
and (power_enough_discharge[this_inverter] or discharge_rates[this_inverter] == 0)
):
self.log("BALANCE: Inverter {} is out of balance low - during discharge, attempting to balance it using inverter {}".format(this_inverter, other_inverter))
balance_reset_discharge[this_inverter] = True
inverters[this_inverter].adjust_discharge_rate(0, notify=False)
elif (
self.balance_inverters_charge
and total_charge_rates > 0
and out_of_balance
and during_charge
and soc_high[this_inverter]
and below_full[other_inverter]
and can_store_pv[this_inverter]
and (power_enough_charge[this_inverter] or charge_rates[this_inverter] == 0)
):
self.log("BALANCE: Inverter {} is out of balance high - during charge, attempting to balance it".format(this_inverter))
balance_reset_charge[this_inverter] = True
inverters[this_inverter].adjust_charge_rate(0, notify=False)
elif self.balance_inverters_crosscharge and during_discharge and total_discharge_rates > 0 and power_enough_charge[this_inverter]:
self.log("BALANCE: Inverter {} is cross charging during discharge, attempting to balance it".format(this_inverter))
if soc_low[this_inverter] and can_power_house[other_inverter]:
balance_reset_discharge[this_inverter] = True
inverters[this_inverter].adjust_discharge_rate(0, notify=False)
else:
balance_reset_charge[this_inverter] = True
inverters[this_inverter].adjust_charge_rate(0, notify=False)
elif self.balance_inverters_crosscharge and during_charge and total_charge_rates > 0 and power_enough_discharge[this_inverter]:
self.log("BALANCE: Inverter {} is cross discharging during charge, attempting to balance it".format(this_inverter))
balance_reset_discharge[this_inverter] = True
inverters[this_inverter].adjust_discharge_rate(0, notify=False)
for id in range(num_inverters):
if not balance_reset_charge.get(id, False) and total_charge_rates != 0 and charge_rates[id] == 0:
self.log("BALANCE: Inverter {} reset charge rate to {} now balanced".format(id, inverter.battery_rate_max_charge * MINUTE_WATT))
inverters[id].adjust_charge_rate(inverter.battery_rate_max_charge * MINUTE_WATT, notify=False)
if not balance_reset_discharge.get(id, False) and total_discharge_rates != 0 and discharge_rates[id] == 0:
self.log("BALANCE: Inverter {} reset discharge rate to {} now balanced".format(id, inverter.battery_rate_max_discharge * MINUTE_WATT))
inverters[id].adjust_discharge_rate(inverter.battery_rate_max_discharge * MINUTE_WATT, notify=False)
self.log("BALANCE: Completed this run")
def log_option_best(self):
"""
Log options
"""
opts = ""
opts += "mode({}) ".format(self.predbat_mode)
opts += "calculate_discharge_oncharge({}) ".format(self.calculate_discharge_oncharge)
opts += "set_discharge_freeze_only({}) ".format(self.set_discharge_freeze_only)
opts += "set_discharge_during_charge({}) ".format(self.set_discharge_during_charge)
opts += "combine_charge_slots({}) ".format(self.combine_charge_slots)
opts += "combine_discharge_slots({}) ".format(self.combine_discharge_slots)
opts += "combine_rate_threshold({}) ".format(self.combine_rate_threshold)
opts += "best_soc_min({} kWh) ".format(self.best_soc_min)
opts += "best_soc_max({} kWh) ".format(self.best_soc_max)
opts += "best_soc_keep({} kWh) ".format(self.best_soc_keep)
opts += "inverter_loss({} %) ".format(int((1 - self.inverter_loss) * 100.0))
opts += "battery_loss({} %) ".format(int((1 - self.battery_loss) * 100.0))
opts += "battery_loss_discharge ({} %) ".format(int((1 - self.battery_loss_discharge) * 100.0))
opts += "inverter_hybrid({}) ".format(self.inverter_hybrid)
opts += "metric_min_improvement({} p) ".format(self.metric_min_improvement)
opts += "metric_min_improvement_discharge({} p) ".format(self.metric_min_improvement_discharge)
opts += "metric_battery_cycle({} p/kWh) ".format(self.metric_battery_cycle)
opts += "metric_battery_value_scaling({} x) ".format(self.metric_battery_value_scaling)
if self.carbon_enable:
opts += "metric_carbon({} p/Kg) ".format(self.carbon_metric)
self.log("Calculate Best options: " + opts)
def history_to_future_rates(self, rates, offset):
"""
Shift rates from the past into a future array
"""
future_rates = {}
for minute in range(0, self.forecast_minutes):
future_rates[minute] = rates.get(minute - offset, 0.0)
return future_rates
def calculate_yesterday(self):
"""
Calculate the base plan for yesterday
"""
yesterday_load_step = self.step_data_history(self.load_minutes, 0, forward=False, scale_today=1.0, scale_fixed=1.0, base_offset=24 * 60 + self.minutes_now)
yesterday_pv_step = self.step_data_history(self.pv_today, 0, forward=False, scale_today=1.0, scale_fixed=1.0, base_offset=24 * 60 + self.minutes_now)
yesterday_pv_step_zero = self.step_data_history(None, 0, forward=False, scale_today=1.0, scale_fixed=1.0, base_offset=24 * 60 + self.minutes_now)
# Get SOC history to find yesterday SOC
soc_kwh_data = self.get_history_wrapper(entity_id=self.prefix + ".soc_kw_h0", days=2)
if not soc_kwh_data:
self.log("Warn: No SOC data found for yesterday")
return
soc_kwh = self.minute_data(
soc_kwh_data[0],
2,
self.now_utc,
"state",
"last_updated",
backwards=True,
clean_increment=False,
smoothing=False,
divide_by=1.0,
scale=1.0,
required_unit="kWh",
)
try:
soc_yesterday = float(self.get_state_wrapper(self.prefix + ".savings_total_soc", default=0.0))
except (ValueError, TypeError):
soc_yesterday = 0.0
# Shift rates back
past_rates = self.history_to_future_rates(self.rate_import, 24 * 60)
past_rates_export = self.history_to_future_rates(self.rate_export, 24 * 60)
# Assume user might charge at the lowest rate only, for fix tariff
charge_window_best = []
rate_low = min(past_rates.values())
combine_charge = self.combine_charge_slots
self.combine_charge_slots = True
charge_window_best, lowest, highest = self.rate_scan_window(past_rates, 5, rate_low, False, return_raw=True)
self.combine_charge_slots = combine_charge
charge_limit_best = [self.soc_max for c in range(len(charge_window_best))]
self.log("Yesterday basic charge window best: {} charge limit best: {}".format(charge_window_best, charge_limit_best))
# Get Cost yesterday
cost_today_data = self.get_history_wrapper(entity_id=self.prefix + ".cost_today", days=2)
if not cost_today_data:
self.log("Warn: No cost_today data for yesterday")
return
cost_data = self.minute_data(cost_today_data[0], 2, self.now_utc, "state", "last_updated", backwards=True, clean_increment=False, smoothing=False, divide_by=1.0, scale=1.0)
cost_yesterday = cost_data.get(self.minutes_now + 5, 0.0)
# Save state
self.dashboard_item(
self.prefix + ".cost_yesterday",
state=self.dp2(cost_yesterday),
attributes={
"friendly_name": "Cost yesterday",
"state_class": "measurement",
"unit_of_measurement": "p",
"icon": "mdi:currency-usd",
},
)
# Save step data for debug
if self.debug_enable:
self.yesterday_load_step = yesterday_load_step
self.yesterday_pv_step = yesterday_pv_step
# Save state
minutes_now = self.minutes_now
midnight_utc = self.midnight_utc
forecast_minutes = self.forecast_minutes
cost_today_sofar = self.cost_today_sofar
import_today_now = self.import_today_now
export_today_now = self.export_today_now
pv_today_now = self.pv_today_now
carbon_today_sofar = self.carbon_today_sofar
soc_kw = self.soc_kw
car_charging_hold = self.car_charging_hold
iboost_energy_subtract = self.iboost_energy_subtract
load_minutes_now = self.load_minutes_now
soc_max = self.soc_max
rate_import = self.rate_import
rate_export = self.rate_export
iboost_enable = self.iboost_enable
num_cars = self.num_cars
# Fake to yesterday state
self.minutes_now = 0
self.cost_today_sofar = 0
self.import_today_now = 0
self.export_today_now = 0
self.carbon_today_sofar = 0
self.midnight_utc = self.midnight_utc - timedelta(days=1)
self.forecast_minutes = 24 * 60
self.pv_today_now = 0
self.soc_kw = soc_yesterday
self.car_charging_hold = False
self.iboost_energy_subtract = False
self.load_minutes_now = 0
self.rate_import = past_rates
self.rate_export = past_rates_export
self.iboost_enable = False
self.num_cars = 0
# Simulate yesterday
self.prediction = Prediction(self, yesterday_pv_step, yesterday_pv_step, yesterday_load_step, yesterday_load_step)
(
metric,
import_kwh_battery,
import_kwh_house,
export_kwh,
soc_min,
final_soc,
soc_min_minute,
battery_cycle,
metric_keep,
final_iboost,
final_carbon_g,
) = self.run_prediction(charge_limit_best, charge_window_best, [], [], False, end_record=(24 * 60), save="yesterday")
# Add back in standing charge which will be in the historical data also
metric += self.metric_standing_charge
# Work out savings
saving = metric - cost_yesterday
self.log(
"Yesterday: Predbat disabled was {}p vs real {}p saving {}p with import {} export {} battery_cycle {} start_soc {} final_soc {}".format(
self.dp2(metric),
self.dp2(cost_yesterday),
self.dp2(saving),
self.dp2(import_kwh_house + import_kwh_battery),
self.dp2(export_kwh),
self.dp2(battery_cycle),
self.dp2(soc_yesterday),
self.dp2(final_soc),
)
)
self.savings_today_predbat = saving
self.savings_today_predbat_soc = final_soc
self.savings_today_actual = cost_yesterday
# Save state
self.dashboard_item(
self.prefix + ".savings_yesterday_predbat",
state=self.dp2(saving),
attributes={
"import": self.dp2(import_kwh_house + import_kwh_battery),
"export": self.dp2(export_kwh),
"battery_cycle": self.dp2(battery_cycle),
"soc_yesterday": self.dp2(soc_yesterday),
"final_soc": self.dp2(final_soc),
"actual_cost": self.dp2(cost_yesterday),
"predicted_cost": self.dp2(metric),
"friendly_name": "Predbat savings yesterday",
"state_class": "measurement",
"unit_of_measurement": "p",
"icon": "mdi:currency-usd",
},
)
# Simulate no PV or battery
self.soc_kw = 0
self.soc_max = 0
self.prediction = Prediction(self, yesterday_pv_step_zero, yesterday_pv_step_zero, yesterday_load_step, yesterday_load_step)
metric, 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(
[], [], [], [], False, end_record=24 * 60
)
# Add back in standing charge which will be in the historical data also
metric += self.metric_standing_charge
# Work out savings
saving = metric - cost_yesterday
self.savings_today_pvbat = saving
self.log(
"Yesterday: No Battery/PV system cost predicted was {}p vs real {}p saving {}p with import {} export {}".format(
self.dp2(metric), self.dp2(cost_yesterday), self.dp2(saving), self.dp2(import_kwh_house + import_kwh_battery), self.dp2(export_kwh)
)
)
# Save state
self.dashboard_item(
self.prefix + ".savings_yesterday_pvbat",
state=self.dp2(saving),
attributes={
"import": self.dp2(import_kwh_house + import_kwh_battery),
"export": self.dp2(export_kwh),
"battery_cycle": self.dp2(battery_cycle),
"actual_cost": self.dp2(cost_yesterday),
"predicted_cost": self.dp2(metric),
"friendly_name": "PV/Battery system savings yesterday",
"state_class": "measurement",
"unit_of_measurement": "p",
"icon": "mdi:currency-usd",
},
)
# Restore state
self.minutes_now = minutes_now
self.midnight_utc = midnight_utc
self.forecast_minutes = forecast_minutes
self.cost_today_sofar = cost_today_sofar
self.import_today_now = import_today_now
self.export_today_now = export_today_now
self.carbon_today_sofar = carbon_today_sofar
self.pv_today_now = pv_today_now
self.soc_kw = soc_kw
self.car_charging_hold = car_charging_hold
self.iboost_energy_subtract = iboost_energy_subtract
self.load_minutes_now = load_minutes_now
self.soc_max = soc_max
self.rate_import = rate_import
self.rate_export = rate_export
self.iboost_enable = iboost_enable
self.num_cars = num_cars
def calculate_plan(self, recompute=True):
"""
Calculate the new plan (best)
sets:
self.charge_window_best
self.charge_limit_best
self.charge_limit_percent_best
self.discharge_window_best
self.discharge_limits_best
"""
# Re-compute plan due to time wrap
if self.plan_last_updated_minutes > self.minutes_now:
self.log("Force recompute due to start of day")
recompute = True
# Shift onto next charge window if required
while self.charge_window_best and not recompute:
window = self.charge_window_best[0]
if window["end"] <= self.minutes_now:
del self.charge_window_best[0]
del self.charge_limit_best[0]
del self.charge_limit_percent_best[0]
self.log("Current charge window has expired, removing it")
else:
break
# Shift onto next discharge window if required
while self.discharge_window_best and not recompute:
window = self.discharge_window_best[0]
if window["end"] <= self.minutes_now:
del self.discharge_window_best[0]
del self.discharge_limits_best[0]
self.log("Current discharge window has expired, removing it")
else:
break
# Recompute?
if recompute:
self.plan_valid = False # In case of crash, plan is now invalid
# Calculate best charge windows
if self.low_rates and self.calculate_best_charge and self.set_charge_window:
# If we are using calculated windows directly then save them
self.charge_window_best = copy.deepcopy(self.low_rates)
else:
# Default best charge window as this one
self.charge_window_best = self.charge_window
# Calculate best discharge windows
if self.high_export_rates:
self.discharge_window_best = copy.deepcopy(self.high_export_rates)
else:
self.discharge_window_best = []
# Pre-fill best charge limit with the current charge limit
self.charge_limit_best = [self.current_charge_limit * self.soc_max / 100.0 for i in range(len(self.charge_window_best))]
self.charge_limit_percent_best = [self.current_charge_limit for i in range(len(self.charge_window_best))]
# Pre-fill best discharge enable with Off
self.discharge_limits_best = [100.0 for i in range(len(self.discharge_window_best))]
self.end_record = self.forecast_minutes
# Show best windows
self.log("Best charge window {}".format(self.window_as_text(self.charge_window_best, self.charge_limit_percent_best)))
self.log("Best discharge window {}".format(self.window_as_text(self.discharge_window_best, self.discharge_limits_best)))
# Created optimised step data
self.metric_cloud_coverage = self.get_cloud_factor(self.minutes_now, self.pv_forecast_minute, self.pv_forecast_minute10)
self.metric_load_divergence = self.get_load_divergence(self.minutes_now, self.load_minutes)
load_minutes_step = self.step_data_history(
self.load_minutes,
self.minutes_now,
forward=False,
scale_today=self.load_inday_adjustment,
scale_fixed=1.0,
type_load=True,
load_forecast=self.load_forecast,
load_scaling_dynamic=self.load_scaling_dynamic,
cloud_factor=self.metric_load_divergence,
)
load_minutes_step10 = self.step_data_history(
self.load_minutes,
self.minutes_now,
forward=False,
scale_today=self.load_inday_adjustment,
scale_fixed=self.load_scaling10,
type_load=True,
load_forecast=self.load_forecast,
load_scaling_dynamic=self.load_scaling_dynamic,
cloud_factor=min(self.metric_load_divergence + 0.5, 1.0) if self.metric_load_divergence else None,
)
pv_forecast_minute_step = self.step_data_history(self.pv_forecast_minute, self.minutes_now, forward=True, cloud_factor=self.metric_cloud_coverage)
pv_forecast_minute10_step = self.step_data_history(
self.pv_forecast_minute10, self.minutes_now, forward=True, cloud_factor=min(self.metric_cloud_coverage + 0.2, 1.0) if self.metric_cloud_coverage else None
)
# Save step data for debug
if self.debug_enable:
self.load_minutes_step = load_minutes_step
self.load_minutes_step10 = load_minutes_step10
self.pv_forecast_minute_step = pv_forecast_minute_step
self.pv_forecast_minute10_step = pv_forecast_minute10_step
# Yesterday data
if recompute and self.calculate_savings:
self.calculate_yesterday()
# Creation prediction object
self.prediction = Prediction(self, pv_forecast_minute_step, pv_forecast_minute10_step, load_minutes_step, load_minutes_step10)
# Create pool
if not self.pool:
threads = self.get_arg("threads", "auto")
if threads == "auto":
self.log("Creating pool of {} processes to match your CPU count".format(cpu_count()))
self.pool = Pool(processes=cpu_count())
elif threads:
self.log("Creating pool of {} processes as per apps.yaml".format(int(threads)))
self.pool = Pool(processes=int(threads))
else:
self.log("Not using threading as threads is set to 0 in apps.yaml")
# Simulate current settings to get initial data
metric, 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(
self.charge_limit, self.charge_window, self.discharge_window, self.discharge_limits, False, end_record=self.end_record
)
# Try different battery SOCs to get the best result
if recompute:
self.rate_best_cost_threshold_charge = None
self.rate_best_cost_threshold_discharge = None
if self.calculate_best and recompute:
# Recomputing the plan
self.log_option_best()
# Full plan
self.optimise_all_windows(metric, metric_keep)
# Tweak plan
if self.calculate_tweak_plan:
self.tweak_plan(self.end_record, metric, metric_keep)
# Remove charge windows that overlap with discharge windows
self.charge_limit_best, self.charge_window_best = remove_intersecting_windows(
self.charge_limit_best, self.charge_window_best, self.discharge_limits_best, self.discharge_window_best
)
# Filter out any unused discharge windows
if self.calculate_best_discharge and self.discharge_window_best:
# Filter out the windows we disabled
self.discharge_limits_best, self.discharge_window_best = self.discard_unused_discharge_slots(self.discharge_limits_best, self.discharge_window_best)
# Clipping windows
if self.discharge_window_best:
# Re-run prediction to get data for clipping
(
best_metric,
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(
self.charge_limit_best,
self.charge_window_best,
self.discharge_window_best,
self.discharge_limits_best,
False,
end_record=self.end_record,
)
# Work out record windows
record_discharge_windows = max(self.max_charge_windows(self.end_record + self.minutes_now, self.discharge_window_best), 1)
# Discharge slot clipping
self.discharge_window_best, self.discharge_limits_best = self.clip_discharge_slots(
self.minutes_now, self.predict_soc, self.discharge_window_best, self.discharge_limits_best, record_discharge_windows, PREDICT_STEP
)
# Filter out the windows we disabled during clipping
self.discharge_limits_best, self.discharge_window_best = self.discard_unused_discharge_slots(self.discharge_limits_best, self.discharge_window_best)
self.log("Discharge windows filtered {}".format(self.window_as_text(self.discharge_window_best, self.discharge_limits_best)))
# Filter out any unused charge slots
if self.calculate_best_charge and self.charge_window_best:
# Re-run prediction to get data for clipping
(
best_metric,
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(
self.charge_limit_best,
self.charge_window_best,
self.discharge_window_best,
self.discharge_limits_best,
False,
end_record=self.end_record,
)
# Initial charge slot filter
if self.set_charge_window:
record_charge_windows = max(self.max_charge_windows(self.end_record + self.minutes_now, self.charge_window_best), 1)
self.charge_limit_best, self.charge_window_best = self.discard_unused_charge_slots(self.charge_limit_best, self.charge_window_best, self.reserve)
# Charge slot clipping
record_charge_windows = max(self.max_charge_windows(self.end_record + self.minutes_now, self.charge_window_best), 1)
self.charge_window_best, self.charge_limit_best = self.clip_charge_slots(
self.minutes_now, self.predict_soc, self.charge_window_best, self.charge_limit_best, record_charge_windows, PREDICT_STEP
)
if self.set_charge_window:
# Filter out the windows we disabled during clipping
self.charge_limit_best, self.charge_window_best = self.discard_unused_charge_slots(self.charge_limit_best, self.charge_window_best, self.reserve)
self.charge_limit_percent_best = calc_percent_limit(self.charge_limit_best, self.soc_max)
self.log("Filtered charge windows {} reserve {}".format(self.window_as_text(self.charge_window_best, self.charge_limit_percent_best), self.reserve))
else:
self.charge_limit_percent_best = calc_percent_limit(self.charge_limit_best, self.soc_max)
self.log("Unfiltered charge windows {} reserve {}".format(self.window_as_text(self.charge_window_best, self.charge_limit_percent_best), self.reserve))
# Plan is now valid
self.plan_valid = True
self.plan_last_updated = self.now_utc
self.plan_last_updated_minutes = self.minutes_now
# Final simulation of base
metric, 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(
self.charge_limit, self.charge_window, self.discharge_window, self.discharge_limits, False, save="base", end_record=self.end_record
)
(
metricb10,
import_kwh_batteryb10,
import_kwh_houseb10,
export_kwhb10,
soc_minb10,
socb10,
soc_min_minuteb10,
battery_cycle10,
metric_keep10,
final_iboost10,
final_carbon_g10,
) = self.run_prediction(
self.charge_limit,
self.charge_window,
self.discharge_window,
self.discharge_limits,
True,
save="base10",
end_record=self.end_record,
)
if self.calculate_best:
# Final simulation of best, do 10% and normal scenario
(
best_metric10,
import_kwh_battery10,
import_kwh_house10,
export_kwh10,
soc_min10,
soc10,
soc_min_minute10,
battery_cycle10,
metric_keep10,
final_iboost10,
final_carbon_g10,
) = self.run_prediction(
self.charge_limit_best,
self.charge_window_best,
self.discharge_window_best,
self.discharge_limits_best,
True,
save="best10",
end_record=self.end_record,
)
(
best_metric,
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(
self.charge_limit_best,
self.charge_window_best,
self.discharge_window_best,
self.discharge_limits_best,
False,
save="best",
end_record=self.end_record,
)
# round charge_limit_best (kWh) to 2 decimal places and discharge_limits_best (percentage) to nearest whole number
self.charge_limit_best = [self.dp2(elem) for elem in self.charge_limit_best]
self.discharge_limits_best = [self.dp0(elem) for elem in self.discharge_limits_best]
self.log(
"Best charging limit socs {} export {} gives import battery {} house {} export {} metric {} metric10 {}".format(
self.charge_limit_best,
self.discharge_limits_best,
self.dp2(import_kwh_battery),
self.dp2(import_kwh_house),
self.dp2(export_kwh),
self.dp2(best_metric),
self.dp2(best_metric10),
)
)
# Publish charge and discharge window best
self.charge_limit_percent_best = calc_percent_limit(self.charge_limit_best, self.soc_max)
self.publish_charge_limit(self.charge_limit_best, self.charge_window_best, self.charge_limit_percent_best, best=True, soc=self.predict_soc_best)
self.publish_discharge_limit(self.discharge_window_best, self.discharge_limits_best, best=True)
# HTML data
self.publish_html_plan(pv_forecast_minute_step, pv_forecast_minute10_step, load_minutes_step, load_minutes_step10, self.end_record)
# Destroy pool
if self.pool:
try:
self.pool.close()
self.pool.join()
except Exception as e:
self.log("Warn: failed to close thread pool: {}".format(e))
self.log("Warn: " + traceback.format_exc())
self.pool = None
# Return if we recomputed or not
return recompute
def reset_inverter(self):
"""
Reset inverter to safe mode
"""
if not self.set_read_only or (self.inverter_needs_reset_force in ["set_read_only"]):
# Don't reset in read only mode unless forced
for inverter in self.inverters:
self.log(
"Reset inverter settings to safe mode (set_charge_window={} set_discharge_window={} force={})".format(
self.set_charge_window, self.set_discharge_window, self.inverter_needs_reset_force
)
)
if self.set_charge_window or (self.inverter_needs_reset_force in ["set_read_only", "mode"]):
inverter.adjust_charge_rate(inverter.battery_rate_max_charge * MINUTE_WATT)
inverter.disable_charge_window()
inverter.adjust_battery_target(100.0, False)
inverter.adjust_pause_mode()
self.isCharging = False
if self.set_charge_window or self.set_discharge_window or (self.inverter_needs_reset_force in ["set_read_only", "mode"]):
inverter.adjust_reserve(0)
if self.set_discharge_window or (self.inverter_needs_reset_force in ["set_read_only", "mode"]):
inverter.adjust_discharge_rate(inverter.battery_rate_max_discharge * MINUTE_WATT)
inverter.adjust_force_discharge(False)
self.isDischarging = False
self.inverter_needs_reset = False
self.inverter_needs_reset_force = ""
def execute_plan(self):
status_extra = ""
if self.holiday_days_left > 0:
status = "Idle (Holiday)"
else:
status = "Idle"
if self.inverter_needs_reset:
self.reset_inverter()
isCharging = False
isDischarging = False
for inverter in self.inverters:
# Read-only mode
if self.set_read_only:
status = "Read-Only"
continue
# Inverter is in calibration mode
if inverter.in_calibration:
status = "Calibration"
inverter.adjust_charge_rate(inverter.battery_rate_max_charge * MINUTE_WATT)
inverter.adjust_discharge_rate(inverter.battery_rate_max_discharge * MINUTE_WATT)
inverter.adjust_battery_target(100.0, False)
inverter.adjust_reserve(0)
self.log("Inverter is in calibration mode, not executing plan and enabling charge/discharge at full rate.")
break
resetDischarge = False
if (not self.set_discharge_during_charge) or (not self.car_charging_from_battery) or self.iboost_prevent_discharge:
# These options mess with discharge rate, so we must reset it when they aren't changing it
resetDischarge = True
resetReserve = False
setReserve = False
disabled_charge_window = False
disabled_discharge = False
# Re-programme charge window based on low rates?
if self.set_charge_window and self.charge_window_best:
# Find the next best window and save it
window = self.charge_window_best[0]
minutes_start = window["start"]
minutes_end = window["end"]
# Combine contiguous windows
for windows in self.charge_window_best:
if minutes_end == windows["start"]:
minutes_end = windows["end"]
if self.debug_enable:
self.log("Combine window with next window {}-{}".format(self.time_abs_str(windows["start"]), self.time_abs_str(windows["end"])))
# Avoid adjust avoid start time forward when it's already started
if (inverter.charge_start_time_minutes < self.minutes_now) and (self.minutes_now >= minutes_start):
self.log(
"Include original charge start {}, keeping this instead of new start {}".format(
self.time_abs_str(inverter.charge_start_time_minutes), self.time_abs_str(minutes_start)
)
)
minutes_start = inverter.charge_start_time_minutes
# Avoid having too long a period to configure as registers only support 24-hours
if (minutes_start < self.minutes_now) and ((minutes_end - minutes_start) >= 24 * 60):
minutes_start = int(self.minutes_now / 30) * 30
self.log("Move on charge window start time to avoid wrap - new start {}".format(self.time_abs_str(minutes_start)))
# Span midnight allowed?
if not inverter.inv_can_span_midnight:
if minutes_start < 24 * 60 and minutes_end >= 24 * 60:
minutes_end = 24 * 60 - 1
# Check if start is within 24 hours of now and end is in the future
if ((minutes_start - self.minutes_now) < (24 * 60)) and (minutes_end > self.minutes_now):
charge_start_time = self.midnight_utc + timedelta(minutes=minutes_start)
charge_end_time = self.midnight_utc + timedelta(minutes=minutes_end)
self.log(
"Charge window will be: {} - {} - current soc {} target {}".format(
charge_start_time, charge_end_time, inverter.soc_percent, self.charge_limit_percent_best[0]
)
)
# Are we actually charging?
if self.minutes_now >= minutes_start and self.minutes_now < minutes_end:
charge_rate = find_charge_rate(
self,
self.minutes_now,
inverter.soc_kw,
window,
self.charge_limit_percent_best[0] * inverter.soc_max / 100.0,
inverter.battery_rate_max_charge,
quiet=False,
)
inverter.adjust_charge_rate(int(charge_rate * MINUTE_WATT))
if inverter.inv_charge_discharge_with_rate:
inverter.adjust_discharge_rate(0)
resetDischarge = False
# Do we disable discharge during charge?
pausedDischarge = False
if not self.set_discharge_during_charge and (inverter.soc_percent >= self.charge_limit_percent_best[0] or not self.set_reserve_enable):
inverter.adjust_discharge_rate(0)
inverter.adjust_pause_mode(pause_discharge=True)
resetDischarge = False
pausedDischarge = True
inverter.adjust_charge_immediate(self.charge_limit_percent_best[0])
if self.set_charge_freeze and (self.charge_limit_best[0] == self.reserve):
if self.set_soc_enable and ((self.set_reserve_enable and self.set_reserve_hold) or inverter.inv_has_timed_pause):
inverter.adjust_pause_mode(pause_discharge=True)
inverter.disable_charge_window()
disabled_charge_window = True
pausedDischarge = True
if not pausedDischarge:
inverter.adjust_pause_mode()
status = "Freeze charging"
status_extra = " target {}%".format(inverter.soc_percent)
else:
if (
self.set_soc_enable
and ((self.set_reserve_enable and self.set_reserve_hold) or inverter.inv_has_timed_pause)
and (inverter.soc_percent >= self.charge_limit_percent_best[0])
and (inverter.reserve_max >= inverter.soc_percent)
):
status = "Hold charging"
inverter.adjust_pause_mode(pause_discharge=True)
inverter.disable_charge_window()
disabled_charge_window = True
else:
status = "Charging"
if not pausedDischarge:
inverter.adjust_pause_mode()
status_extra = " target {}%-{}%".format(inverter.soc_percent, self.charge_limit_percent_best[0])
isCharging = True
if not disabled_charge_window:
# Configure the charge window start/end times if in the time window to set them
if (self.minutes_now < minutes_end) and (
(minutes_start - self.minutes_now) <= self.set_window_minutes or (inverter.charge_start_time_minutes - self.minutes_now) <= self.set_window_minutes
):
if ((minutes_start - self.minutes_now) > self.set_window_minutes) and (minutes_end - self.minutes_now) >= 24 * 60:
self.log("Charge window would wrap, disabling until later")
inverter.disable_charge_window()
else:
# We must re-program if we are about to start a new charge window or the currently configured window is about to start or has started
# If we are going into freeze mode but haven't yet then don't configure the charge window as it will mean a spike of charging first
if not isCharging and self.set_charge_freeze and self.charge_limit_best[0] == self.reserve:
self.log("Charge window will be disabled as freeze charging is planned")
inverter.disable_charge_window()
else:
self.log(
"Configuring charge window now (now {} target set_window_minutes {} charge start time {}".format(
self.time_abs_str(self.minutes_now), self.set_window_minutes, self.time_abs_str(minutes_start)
)
)
inverter.adjust_charge_window(charge_start_time, charge_end_time, self.minutes_now)
else:
if not self.inverter_set_charge_before:
self.log(
"Disabled charge window while waiting for schedule (now {} target set_window_minutes {} charge start time {})".format(
self.time_abs_str(self.minutes_now), self.set_window_minutes, self.time_abs_str(minutes_start)
)
)
inverter.disable_charge_window()
else:
self.log(
"Not setting charging window yet as not within the window (now {} target set_window_minutes {} charge start time {})".format(
self.time_abs_str(self.minutes_now), self.set_window_minutes, self.time_abs_str(minutes_start)
)
)
# Set configured window minutes for the SOC adjustment routine
inverter.charge_start_time_minutes = minutes_start
inverter.charge_end_time_minutes = minutes_end
elif ((minutes_start - self.minutes_now) >= (24 * 60)) and (inverter.charge_start_time_minutes - self.minutes_now) <= self.set_window_minutes:
# No charging require in the next 24 hours
self.log("No charge window required for 24-hours, disabling before the start")
inverter.disable_charge_window()
else:
if not self.inverter_set_charge_before:
self.log("No change to charge window yet, disabled while waiting for schedule.")
inverter.disable_charge_window()
else:
self.log("No change to charge window yet, waiting for schedule.")
elif self.set_charge_window and (inverter.charge_start_time_minutes - self.minutes_now) <= self.set_window_minutes:
# No charge windows
self.log("No charge windows found, disabling before the start")
inverter.disable_charge_window()
elif self.set_charge_window:
self.log("No change to charge window yet, waiting for schedule.")
# Set discharge modes/window?
if self.set_discharge_window and self.discharge_window_best:
window = self.discharge_window_best[0]
minutes_start = window["start"]
minutes_end = window["end"]
# Avoid adjust avoid start time forward when it's already started
if (inverter.discharge_start_time_minutes < self.minutes_now) and (self.minutes_now >= minutes_start):
minutes_start = inverter.discharge_start_time_minutes
# Don't allow overlap with charge window
if minutes_start < inverter.charge_end_time_minutes and minutes_end >= inverter.charge_start_time_minutes:
minutes_start = window["start"]
else:
self.log(
"Include original discharge start {} with our start which is {} (charge start {} end {})".format(
self.time_abs_str(inverter.discharge_start_time_minutes),
self.time_abs_str(minutes_start),
self.time_abs_str(inverter.charge_start_time_minutes),
self.time_abs_str(inverter.charge_end_time_minutes),
)
)
# Avoid having too long a period to configure as registers only support 24-hours
if (minutes_start < self.minutes_now) and ((minutes_end - minutes_start) >= 24 * 60):
minutes_start = int(self.minutes_now / 30) * 30
self.log("Move on discharge window start time to avoid wrap - new start {}".format(self.time_abs_str(minutes_start)))
discharge_adjust = 1
# Span midnight allowed?
if not inverter.inv_can_span_midnight:
if minutes_start < 24 * 60 and minutes_end >= 24 * 60:
minutes_end = 24 * 60 - 1
discharge_adjust = 0
discharge_start_time = self.midnight_utc + timedelta(minutes=minutes_start)
discharge_end_time = self.midnight_utc + timedelta(minutes=(minutes_end + discharge_adjust)) # Add in 1 minute margin to allow Predbat to restore ECO mode
discharge_soc = max((self.discharge_limits_best[0] * self.soc_max) / 100.0, self.reserve, self.best_soc_min)
self.log("Next discharge window will be: {} - {} at reserve {}".format(discharge_start_time, discharge_end_time, self.discharge_limits_best[0]))
if (self.minutes_now >= minutes_start) and (self.minutes_now < minutes_end) and (self.discharge_limits_best[0] < 100.0):
if not self.set_discharge_freeze_only and ((self.soc_kw - PREDICT_STEP * inverter.battery_rate_max_discharge_scaled) >= discharge_soc):
self.log("Discharging now - current SOC {} and target {}".format(self.soc_kw, self.dp2(discharge_soc)))
inverter.adjust_discharge_rate(inverter.battery_rate_max_discharge * MINUTE_WATT)
inverter.adjust_force_discharge(True, discharge_start_time, discharge_end_time)
if inverter.inv_charge_discharge_with_rate:
inverter.adjust_charge_rate(0)
else:
inverter.adjust_charge_rate(inverter.battery_rate_max_charge * MINUTE_WATT)
inverter.adjust_pause_mode()
resetDischarge = False
isDischarging = True
if self.set_reserve_enable:
inverter.adjust_reserve(self.discharge_limits_best[0])
setReserve = True
status = "Discharging"
status_extra = " target {}%-{}%".format(inverter.soc_percent, self.discharge_limits_best[0])
# Immediate discharge mode
inverter.adjust_discharge_immediate(self.discharge_limits_best[0])
else:
inverter.adjust_force_discharge(False)
disabled_discharge = True
if self.set_discharge_freeze and (self.discharge_limits_best[0] == 99):
# In discharge freeze mode we disable charging during discharge slots
inverter.adjust_charge_rate(0)
if inverter.inv_charge_discharge_with_rate:
inverter.adjust_discharge_rate(0)
inverter.adjust_pause_mode(pause_charge=True)
self.log("Discharge Freeze as discharge is now at/below target - current SOC {} and target {}".format(self.soc_kw, discharge_soc))
status = "Freeze discharging"
status_extra = " target {}%-{}%".format(inverter.soc_percent, self.discharge_limits_best[0])
isDischarging = True
else:
status = "Hold discharging"
status_extra = " target {}%-{}%".format(inverter.soc_percent, self.discharge_limits_best[0])
self.log(
"Discharge Hold (ECO mode) as discharge is now at/below target or freeze only is set - current SOC {} and target {}".format(
self.soc_kw, discharge_soc
)
)
inverter.adjust_pause_mode()
resetReserve = True
else:
if (self.minutes_now < minutes_end) and ((minutes_start - self.minutes_now) <= self.set_window_minutes) and self.discharge_limits_best[0]:
inverter.adjust_force_discharge(False, discharge_start_time, discharge_end_time)
resetReserve = True
else:
self.log(
"Setting ECO mode as we are not yet within the discharge window - next time is {} - {}".format(
self.time_abs_str(minutes_start), self.time_abs_str(minutes_end)
)
)
inverter.adjust_force_discharge(False)
resetReserve = True
if self.set_discharge_freeze and not isCharging:
# In discharge freeze mode we disable charging during discharge slots, so turn it back on otherwise
inverter.adjust_charge_rate(inverter.battery_rate_max_charge * MINUTE_WATT)
elif self.set_discharge_window:
self.log("Setting ECO mode as no discharge window planned")
inverter.adjust_force_discharge(False)
resetReserve = True
if self.set_discharge_freeze and not isCharging:
# In discharge freeze mode we disable charging during discharge slots, so turn it back on otherwise
inverter.adjust_charge_rate(inverter.battery_rate_max_charge * MINUTE_WATT)
# Car charging from battery disable?
carHolding = False
if not self.car_charging_from_battery:
for car_n in range(self.num_cars):
if self.car_charging_slots[car_n]:
window = self.car_charging_slots[car_n][0]
self.log(
"Car charging from battery is off, next slot for car {} is {} - {}".format(car_n, self.time_abs_str(window["start"]), self.time_abs_str(window["end"]))
)
if self.minutes_now >= window["start"] and self.minutes_now < window["end"]:
# Don't disable discharge during force charge/discharge slots but otherwise turn it off to prevent
# from draining the battery
if status not in ["Discharging", "Charging"]:
inverter.adjust_discharge_rate(0)
inverter.adjust_pause_mode(pause_discharge=True)
resetDischarge = False
carHolding = True
self.log("Disabling battery discharge while the car {} is charging".format(car_n))
if status != "Idle":
status += ", Hold for car"
else:
status = "Hold for car"
break
# Iboost running?
boostHolding = False
if self.iboost_enable and self.iboost_prevent_discharge and self.iboost_running_full and status not in ["Discharging", "Charging"]:
inverter.adjust_discharge_rate(0)
inverter.adjust_pause_mode(pause_discharge=True)
resetDischarge = False
boostHolding = True
self.log("Disabling battery discharge while iBoost is running")
if status != "Idle":
status += ", Hold for iBoost"
else:
status = "Hold for iBoost"
# Charging/Discharging off via service
if not isCharging and (not isDischarging or disabled_discharge) and self.set_charge_window:
inverter.adjust_charge_immediate(0)
# Pause charge off
if not isCharging and not isDischarging and not carHolding and not boostHolding:
inverter.adjust_pause_mode()
# Reset discharge rate?
if resetDischarge:
inverter.adjust_discharge_rate(inverter.battery_rate_max_discharge * MINUTE_WATT)
# Set the SOC just before or within the charge window
if self.set_soc_enable:
if (isDischarging and not disabled_discharge) and not self.set_reserve_enable:
# If we are discharging and not setting reserve then we should reset the target SOC to 0%
# as some inverters can use this as a target for discharge
inverter.adjust_battery_target(self.discharge_limits_best[0], False)
elif (
self.charge_limit_best
and (self.minutes_now < inverter.charge_end_time_minutes)
and ((inverter.charge_start_time_minutes - self.minutes_now) <= self.set_soc_minutes)
and not (disabled_charge_window)
):
if inverter.inv_has_charge_enable_time or isCharging:
# In charge freeze hold the target SOC at the current value
if self.set_charge_freeze and (self.charge_limit_best[0] == self.reserve):
if isCharging:
self.log("Within charge freeze setting target soc to current soc {}".format(inverter.soc_percent))
inverter.adjust_battery_target(inverter.soc_percent, True)
else:
# Not yet in the freeze, hold at 100% target SOC
inverter.adjust_battery_target(100.0, False)
else:
# If not charging and not hybrid we should reset the target % to 100 to avoid losing solar
if not self.inverter_hybrid and self.inverter_soc_reset and not isCharging:
inverter.adjust_battery_target(100.0, False)
else:
inverter.adjust_battery_target(self.charge_limit_percent_best[0], isCharging)
else:
if not inverter.inv_has_target_soc:
# If the inverter doesn't support target soc and soc_enable is on then do that logic here:
inverter.mimic_target_soc(0)
elif not self.inverter_hybrid and self.inverter_soc_reset:
# AC Coupled, charge to 0 on solar
inverter.adjust_battery_target(100.0, False)
else:
# Hybrid, no charge timer, set target soc back to 0
inverter.adjust_battery_target(0, False)
else:
if not inverter.inv_has_target_soc:
# If the inverter doesn't support target soc and soc_enable is on then do that logic here:
inverter.mimic_target_soc(0)
elif not self.inverter_hybrid and self.inverter_soc_reset:
if (
self.charge_limit_best
and (self.minutes_now >= inverter.charge_start_time_minutes)
and (self.minutes_now < inverter.charge_end_time_minutes)
and (not disabled_charge_window)
):
self.log(
"Within the charge window, holding SOC setting {} (now {} target set_soc_minutes {} charge start time {})".format(
self.charge_limit_percent_best[0],
self.time_abs_str(self.minutes_now),
self.set_soc_minutes,
self.time_abs_str(inverter.charge_start_time_minutes),
)
)
else:
self.log(
"Resetting charging SOC as we are not within the window or charge is disabled and inverter_soc_reset is enabled (now {} target set_soc_minutes {} charge start time {})".format(
self.time_abs_str(self.minutes_now), self.set_soc_minutes, self.time_abs_str(inverter.charge_start_time_minutes)
)
)
inverter.adjust_battery_target(100.0, False)
else:
self.log(
"Not setting charging SOC as we are not within the window (now {} target set_soc_minutes {} charge start time {})".format(
self.time_abs_str(self.minutes_now), self.set_soc_minutes, self.time_abs_str(inverter.charge_start_time_minutes)
)
)
if not inverter.inv_has_charge_enable_time:
inverter.adjust_battery_target(0, False)
# If we should set reserve during charging
if self.set_soc_enable and self.set_reserve_enable and not setReserve:
if isCharging:
# Compute limit to account for freeze setting
if self.charge_limit_best[0] == self.reserve:
if self.set_charge_freeze:
limit = inverter.soc_percent
else:
limit = 0
else:
limit = self.charge_limit_percent_best[0]
# Only set the reserve when we reach the desired percent
if (inverter.soc_percent > limit) or (not disabled_charge_window):
self.log("Adjust reserve to default as SOC {} % is above target {} % or charging active".format(inverter.soc_percent, limit))
inverter.adjust_reserve(0)
else:
self.log("Adjust reserve to target charge {} % (set_reserve_enable is true)".format(limit))
inverter.adjust_reserve(min(limit + 1, 100))
resetReserve = False
else:
self.log("Adjust reserve to default (as set_reserve_enable is true)")
inverter.adjust_reserve(0)
resetReserve = False
# Reset reserve as discharge is enable but not running right now
if self.set_reserve_enable and resetReserve and not setReserve:
inverter.adjust_reserve(0)
# Set the charge/discharge status information
self.set_charge_discharge_status(isCharging and not disabled_charge_window, isDischarging and not disabled_discharge)
self.isCharging = isCharging
self.isDischarging = isDischarging
return status, status_extra
def fetch_carbon_intensity(self, entity_id):
"""
Fetch the carbon intensity from the sensor
Returns a dictionary with the carbon intensity data for the next 24 hours
And a secondary dictionary with the carbon intensity data for the last 24 hours
:param entity_id: The entity_id of the sensor
"""
data_all = []
carbon_data = {}
carbon_history = {}
if entity_id:
self.log("Fetching carbon intensity data from {}".format(entity_id))
data_all = self.get_state_wrapper(entity_id=entity_id, attribute="forecast")
if data_all:
carbon_data = self.minute_data(data_all, self.forecast_days, self.now_utc, "intensity", "from", backwards=False, to_key="to")
entity_id = self.prefix + ".carbon_now"
state = self.get_state_wrapper(entity_id=entity_id)
if state is not None:
try:
carbon_history = self.minute_data_import_export(self.now_utc, entity_id, required_unit="g/kWh", increment=False, smoothing=False)
except ValueError:
self.log("Warn: No carbon intensity history in sensor {}".format(entity_id))
else:
self.log("Warn: No carbon intensity history in sensor {}".format(entity_id))
return carbon_data, carbon_history
def fetch_octopus_rates(self, entity_id, adjust_key=None):
"""
Fetch the Octopus rates from the sensor
:param entity_id: The entity_id of the sensor
:param adjust_key: The key use to find Octopus Intelligent adjusted rates
"""
data_all = []
rate_data = {}
if entity_id:
# From 9.0.0 of the Octopus plugin the data is split between previous rate, current rate and next rate
# and the sensor is replaced with an event - try to support the old settings and find the new events
if self.debug_enable:
self.log("Fetch Octopus rates from {}".format(entity_id))
# Previous rates
if "_current_rate" in entity_id:
# Try as event
prev_rate_id = entity_id.replace("_current_rate", "_previous_day_rates").replace("sensor.", "event.")
data_import = self.get_state_wrapper(entity_id=prev_rate_id, attribute="rates")
if data_import:
data_all += data_import
else:
prev_rate_id = entity_id.replace("_current_rate", "_previous_rate")
data_import = self.get_state_wrapper(entity_id=prev_rate_id, attribute="all_rates")
if data_import:
data_all += data_import
else:
self.log("Warn: No Octopus data in sensor {} attribute 'all_rates'".format(prev_rate_id))
# Current rates
if "_current_rate" in entity_id:
current_rate_id = entity_id.replace("_current_rate", "_current_day_rates").replace("sensor.", "event.")
else:
current_rate_id = entity_id
data_import = (
self.get_state_wrapper(entity_id=current_rate_id, attribute="rates")
or self.get_state_wrapper(entity_id=current_rate_id, attribute="all_rates")
or self.get_state_wrapper(entity_id=current_rate_id, attribute="raw_today")
)
if data_import:
data_all += data_import
else:
self.log("Warn: No Octopus data in sensor {} attribute 'all_rates' / 'rates' / 'raw_today'".format(current_rate_id))
# Next rates
if "_current_rate" in entity_id:
next_rate_id = entity_id.replace("_current_rate", "_next_day_rates").replace("sensor.", "event.")
data_import = self.get_state_wrapper(entity_id=next_rate_id, attribute="rates")
if data_import:
data_all += data_import
else:
next_rate_id = entity_id.replace("_current_rate", "_next_rate")
data_import = self.get_state_wrapper(entity_id=next_rate_id, attribute="all_rates")
if data_import:
data_all += data_import
else:
# Nordpool tomorrow
data_import = self.get_state_wrapper(entity_id=current_rate_id, attribute="raw_tomorrow")
if data_import:
data_all += data_import
if data_all:
rate_key = "rate"
from_key = "from"
to_key = "to"
scale = 1.0
if rate_key not in data_all[0]:
rate_key = "value_inc_vat"
from_key = "valid_from"
to_key = "valid_to"
if from_key not in data_all[0]:
from_key = "start"
to_key = "end"
scale = 100.0
if rate_key not in data_all[0]:
rate_key = "value"
rate_data = self.minute_data(
data_all, self.forecast_days + 1, self.midnight_utc, rate_key, from_key, backwards=False, to_key=to_key, adjust_key=adjust_key, scale=scale
)
return rate_data
def fetch_sensor_data(self):
"""
Fetch all the data, e.g. energy rates, load, PV predictions, car plan etc.
"""
self.rate_import = {}
self.rate_import_replicated = {}
self.rate_export = {}
self.rate_export_replicated = {}
self.rate_slots = []
self.io_adjusted = {}
self.low_rates = []
self.high_export_rates = []
self.octopus_slots = []
self.cost_today_sofar = 0
self.carbon_today_sofar = 0
self.import_today = {}
self.export_today = {}
self.pv_today = {}
self.load_minutes = {}
self.load_minutes_age = 0
self.load_forecast = {}
self.pv_forecast_minute = {}
self.pv_forecast_minute10 = {}
self.load_scaling_dynamic = {}
self.carbon_intensity = {}
self.carbon_history = {}
# iBoost load data
if "iboost_energy_today" in self.args:
self.iboost_energy_today, iboost_energy_age = self.minute_data_load(self.now_utc, "iboost_energy_today", self.max_days_previous, required_unit="kWh")
if iboost_energy_age >= 1:
self.iboost_today = self.dp2(abs(self.iboost_energy_today[0] - self.iboost_energy_today[self.minutes_now]))
self.log("iBoost energy today from sensor reads {} kWh".format(self.iboost_today))
# Fetch extra load forecast
self.load_forecast = self.fetch_extra_load_forecast(self.now_utc)
# Load previous load data
if self.get_arg("ge_cloud_data", False):
self.download_ge_data(self.now_utc)
else:
# Load data
if "load_today" in self.args:
self.load_minutes, self.load_minutes_age = self.minute_data_load(self.now_utc, "load_today", self.max_days_previous, required_unit="kWh")
self.log("Found {} load_today datapoints going back {} days".format(len(self.load_minutes), self.load_minutes_age))
self.load_minutes_now = max(self.load_minutes.get(0, 0) - self.load_minutes.get(self.minutes_now, 0), 0)
else:
if self.load_forecast:
self.log("Using load forecast from load_forecast sensor")
self.load_minutes_now = self.load_forecast.get(0, 0)
self.load_minutes_age = 0
else:
self.log("Error: You have not set load_today or load_forecast, you will have no load data")
self.record_status(message="Error: load_today not set correctly", had_errors=True)
raise ValueError
# Load import today data
if "import_today" in self.args:
self.import_today = self.minute_data_import_export(self.now_utc, "import_today", scale=self.import_export_scaling, required_unit="kWh")
self.import_today_now = max(self.import_today.get(0, 0) - self.import_today.get(self.minutes_now, 0), 0)
else:
self.log("Warn: You have not set import_today in apps.yaml, you will have no previous import data")
# Load export today data
if "export_today" in self.args:
self.export_today = self.minute_data_import_export(self.now_utc, "export_today", scale=self.import_export_scaling, required_unit="kWh")
self.export_today_now = max(self.export_today.get(0, 0) - self.export_today.get(self.minutes_now, 0), 0)
else:
self.log("Warn: You have not set export_today in apps.yaml, you will have no previous export data")
# PV today data
if "pv_today" in self.args:
self.pv_today = self.minute_data_import_export(self.now_utc, "pv_today", required_unit="kWh")
self.pv_today_now = max(self.pv_today.get(0, 0) - self.pv_today.get(self.minutes_now, 0), 0)
else:
self.log("Warn: You have not set pv_today in apps.yaml, you will have no previous pv data")
# Car charging hold - when enabled battery is held during car charging in simulation
self.car_charging_energy = self.load_car_energy(self.now_utc)
# Log current values
self.log(
"Current data so far today: load {} kWh import {} kWh export {} kWh pv {} kWh".format(
self.dp2(self.load_minutes_now), self.dp2(self.import_today_now), self.dp2(self.export_today_now), self.dp2(self.pv_today_now)
)
)
# futurerate data
self.future_energy_rates_import, self.future_energy_rates_export = self.futurerate_analysis()
if "rates_import_octopus_url" in self.args:
# Fixed URL for rate import
self.log("Downloading import rates directly from URL {}".format(self.get_arg("rates_import_octopus_url", indirect=False)))
self.rate_import = self.download_octopus_rates(self.get_arg("rates_import_octopus_url", indirect=False))
elif "metric_octopus_import" in self.args:
# Octopus import rates
entity_id = self.get_arg("metric_octopus_import", None, indirect=False)
self.rate_import = self.fetch_octopus_rates(entity_id, adjust_key="is_intelligent_adjusted")
if not self.rate_import:
self.log("Error: metric_octopus_import is not set correctly or no energy rates can be read")
self.record_status(message="Error: metric_octopus_import not set correctly or no energy rates can be read", had_errors=True)
raise ValueError
else:
# Basic rates defined by user over time
self.rate_import = self.basic_rates(self.get_arg("rates_import", [], indirect=False), "import")
# Gas rates if set
if "metric_octopus_gas" in self.args:
entity_id = self.get_arg("metric_octopus_gas", None, indirect=False)
self.rate_gas = self.fetch_octopus_rates(entity_id)
if not self.rate_gas:
self.log("Error: metric_octopus_gas is not set correctly or no energy rates can be read")
self.record_status(message="Error: rate_import_gas not set correctly or no energy rates can be read", had_errors=True)
raise ValueError
self.rate_gas, self.rate_gas_replicated = self.rate_replicate(self.rate_gas, is_import=False, is_gas=False)
self.rate_gas = self.rate_scan_gas(self.rate_gas, print=True)
elif "rates_gas" in self.args:
self.rate_gas = self.basic_rates(self.get_arg("rates_gas", [], indirect=False), "gas")
self.rate_gas, self.rate_gas_replicated = self.rate_replicate(self.rate_gas, is_import=False, is_gas=False)
self.rate_gas = self.rate_scan_gas(self.rate_gas, print=True)
# Carbon intensity data
if self.carbon_enable and ("carbon_intensity" in self.args):
entity_id = self.get_arg("carbon_intensity", None, indirect=False)
self.carbon_intensity, self.carbon_history = self.fetch_carbon_intensity(entity_id)
# Work out current car SOC and limit
self.car_charging_loss = 1 - float(self.get_arg("car_charging_loss"))
# Octopus intelligent slots
if "octopus_intelligent_slot" in self.args:
completed = []
planned = []
vehicle = {}
vehicle_pref = {}
entity_id = self.get_arg("octopus_intelligent_slot", indirect=False)
try:
completed = self.get_state_wrapper(entity_id=entity_id, attribute="completedDispatches") or self.get_state_wrapper(
entity_id=entity_id, attribute="completed_dispatches"
)
planned = self.get_state_wrapper(entity_id=entity_id, attribute="plannedDispatches") or self.get_state_wrapper(entity_id=entity_id, attribute="planned_dispatches")
vehicle = self.get_state_wrapper(entity_id=entity_id, attribute="registeredKrakenflexDevice")
vehicle_pref = self.get_state_wrapper(entity_id=entity_id, attribute="vehicleChargingPreferences")
except (ValueError, TypeError):
self.log("Warn: Unable to get data from {} - octopus_intelligent_slot may not be set correctly".format(entity_id))
self.record_status(message="Error: octopus_intelligent_slot not set correctly", had_errors=True)
# Completed and planned slots
if completed:
self.octopus_slots += completed
if planned:
self.octopus_slots += planned
# Get rate for import to compute charging costs
if self.rate_import:
self.rate_import = self.rate_scan(self.rate_import, print=False)
if self.num_cars >= 1:
# Extract vehicle data if we can get it
if vehicle:
self.car_charging_battery_size[0] = float(vehicle.get("vehicleBatterySizeInKwh", self.car_charging_battery_size[0]))
self.car_charging_rate[0] = float(vehicle.get("chargePointPowerInKw", self.car_charging_rate[0]))
else:
size = self.get_state_wrapper(entity_id=entity_id, attribute="vehicle_battery_size_in_kwh")
rate = self.get_state_wrapper(entity_id=entity_id, attribute="charge_point_power_in_kw")
if size:
self.car_charging_battery_size[0] = size
if rate:
self.car_charging_rate[0] = rate
# Get car charging limit again from car based on new battery size
self.car_charging_limit[0] = self.dp3((float(self.get_arg("car_charging_limit", 100.0, index=0)) * self.car_charging_battery_size[0]) / 100.0)
# Extract vehicle preference if we can get it
if vehicle_pref and self.octopus_intelligent_charging:
octopus_limit = max(float(vehicle_pref.get("weekdayTargetSoc", 100)), float(vehicle_pref.get("weekendTargetSoc", 100)))
octopus_ready_time = vehicle_pref.get("weekdayTargetTime", None)
if not octopus_ready_time:
octopus_ready_time = self.car_charging_plan_time[0]
else:
octopus_ready_time += ":00"
self.car_charging_plan_time[0] = octopus_ready_time
octopus_limit = self.dp3(octopus_limit * self.car_charging_battery_size[0] / 100.0)
self.car_charging_limit[0] = min(self.car_charging_limit[0], octopus_limit)
elif self.octopus_intelligent_charging:
octopus_ready_time = self.get_arg("octopus_ready_time", None)
octopus_limit = self.get_arg("octopus_charge_limit", None)
if octopus_limit:
try:
octopus_limit = float(octopus_limit)
except ValueError:
self.log("Warn: octopus_limit is set to a bad value {} in apps.yaml, must be a number".format(octopus_limit))
octopus_limit = None
if octopus_limit:
octopus_limit = self.dp3(float(octopus_limit) * self.car_charging_battery_size[0] / 100.0)
self.car_charging_limit[0] = min(self.car_charging_limit[0], octopus_limit)
if octopus_ready_time:
self.car_charging_plan_time[0] = octopus_ready_time
# Use octopus slots for charging?
if self.octopus_intelligent_charging:
self.octopus_slots = self.add_now_to_octopus_slot(self.octopus_slots, self.now_utc)
if not self.octopus_intelligent_ignore_unplugged or self.car_charging_planned[0]:
self.car_charging_slots[0] = self.load_octopus_slots(self.octopus_slots)
if self.car_charging_slots[0]:
self.log(
"Car 0 using Octopus Intelligent, charging planned - charging limit {}, ready time {} - battery size {}".format(
self.car_charging_limit[0], self.car_charging_plan_time[0], self.car_charging_battery_size[0]
)
)
self.car_charging_planned[0] = True
else:
self.log("Car 0 using Octopus Intelligent, not charging is planned")
self.car_charging_planned[0] = False
else:
self.log("Car 0 using Octopus Intelligent is unplugged")
self.car_charging_planned[0] = False
else:
# Disable octopus charging if we don't have the slot sensor
self.octopus_intelligent_charging = False
# Work out car SOC and reset next
self.car_charging_soc = [0.0 for car_n in range(self.num_cars)]
self.car_charging_soc_next = [None for car_n in range(self.num_cars)]
for car_n in range(self.num_cars):
if (car_n == 0) and self.car_charging_manual_soc:
self.car_charging_soc[car_n] = self.get_arg("car_charging_manual_soc_kwh")
else:
self.car_charging_soc[car_n] = (self.get_arg("car_charging_soc", 0.0, index=car_n) * self.car_charging_battery_size[car_n]) / 100.0
if self.num_cars:
self.log(
"Cars: SOC kWh: {} Charge limit {} plan time {} battery size {}".format(
self.car_charging_soc, self.car_charging_limit, self.car_charging_plan_time, self.car_charging_battery_size
)
)
if "rates_export_octopus_url" in self.args:
# Fixed URL for rate export
self.log("Downloading export rates directly from URL {}".format(self.get_arg("rates_export_octopus_url", indirect=False)))
self.rate_export = self.download_octopus_rates(self.get_arg("rates_export_octopus_url", indirect=False))
elif "metric_octopus_export" in self.args:
# Octopus export rates
entity_id = self.get_arg("metric_octopus_export", None, indirect=False)
self.rate_export = self.fetch_octopus_rates(entity_id)
if not self.rate_export:
self.log("Warning: metric_octopus_export is not set correctly or no energy rates can be read")
self.record_status(message="Error: metric_octopus_export not set correctly or no energy rates can be read", had_errors=True)
else:
# Basic rates defined by user over time
self.rate_export = self.basic_rates(self.get_arg("rates_export", [], indirect=False), "export")
# Octopus saving session
octopus_saving_slots = []
if "octopus_saving_session" in self.args:
saving_rate = 200 # Default rate if not reported
octopoints_per_penny = self.get_arg("octopus_saving_session_octopoints_per_penny", 8) # Default 8 octopoints per found
entity_id = self.get_arg("octopus_saving_session", indirect=False)
if entity_id:
state = self.get_arg("octopus_saving_session", False)
joined_events = self.get_state_wrapper(entity_id=entity_id, attribute="joined_events")
if not joined_events:
entity_id = entity_id.replace("binary_sensor.", "event.").replace("_sessions", "_session_events")
joined_events = self.get_state_wrapper(entity_id=entity_id, attribute="joined_events")
available_events = self.get_state_wrapper(entity_id=entity_id, attribute="available_events")
if available_events:
for event in available_events:
code = event.get("code", None) # decode the available events structure for code, start/end time & rate
start = event.get("start", None)
end = event.get("end", None)
start_time = self.str2time(start) # reformat the saving session start & end time for improved readability
end_time = self.str2time(end)
saving_rate = event.get("octopoints_per_kwh", saving_rate * octopoints_per_penny) / octopoints_per_penny # Octopoints per pence
if code: # Join the new Octopus saving event and send an alert
self.log(
"Joining Octopus saving event code {} {}-{} at rate {} p/kWh".format(
code, start_time.strftime("%a %d/%m %H:%M"), end_time.strftime("%H:%M"), saving_rate
)
)
self.call_service("octopus_energy/join_octoplus_saving_session_event", event_code=code, entity_id=entity_id)
self.call_notify(
"Predbat: Joined Octopus saving event {}-{}, {} p/kWh".format(start_time.strftime("%a %d/%m %H:%M"), end_time.strftime("%H:%M"), saving_rate)
)
if joined_events:
for event in joined_events:
start = event.get("start", None)
end = event.get("end", None)
saving_rate = event.get("octopoints_per_kwh", saving_rate * octopoints_per_penny) / octopoints_per_penny # Octopoints per pence
if start and end and saving_rate > 0:
# Save the saving slot?
try:
start_time = self.str2time(start)
end_time = self.str2time(end)
diff_time = start_time - self.now_utc
if abs(diff_time.days) <= 3:
self.log(
"Joined Octopus saving session: {}-{} at rate {} p/kWh state {}".format(
start_time.strftime("%a %d/%m %H:%M"), end_time.strftime("%H:%M"), saving_rate, state
)
)
# Save the slot
octopus_saving_slot = {}
octopus_saving_slot["start"] = start
octopus_saving_slot["end"] = end
octopus_saving_slot["rate"] = saving_rate
octopus_saving_slot["state"] = state
octopus_saving_slots.append(octopus_saving_slot)
except ValueError:
self.log(
"Warn: Bad start time for joined Octopus saving session: {}-{} at rate {} p/kWh state {}".format(
start_time.strftime("%a %d/%m %H:%M"), end_time.strftime("%H:%M"), saving_rate, state
)
)
# In saving session that's not reported, assumed 30-minutes
if state and not joined_events:
octopus_saving_slot = {}
octopus_saving_slot["start"] = None
octopus_saving_slot["end"] = None
octopus_saving_slot["rate"] = saving_rate
octopus_saving_slot["state"] = state
octopus_saving_slots.append(octopus_saving_slot)
if state:
self.log("Octopus Saving session is active!")
# Standing charge
self.metric_standing_charge = self.get_arg("metric_standing_charge", 0.0) * 100.0
self.log("Standing charge is set to {} p".format(self.metric_standing_charge))
# Replicate and scan import rates
if self.rate_import:
self.rate_import = self.rate_scan(self.rate_import, print=False)
self.rate_import, self.rate_import_replicated = self.rate_replicate(self.rate_import, self.io_adjusted, is_import=True)
self.rate_import = self.rate_add_io_slots(self.rate_import, self.octopus_slots)
self.load_saving_slot(octopus_saving_slots, export=False, rate_replicate=self.rate_import_replicated)
if "rates_import_override" in self.args:
self.rate_import = self.basic_rates(self.get_arg("rates_import_override", [], indirect=False), "import", self.rate_import, self.rate_import_replicated)
self.rate_import = self.rate_scan(self.rate_import, print=True)
else:
self.log("Warning: No import rate data provided")
self.record_status(message="Error: No import rate data provided", had_errors=True)
# Replicate and scan export rates
if self.rate_export:
self.rate_export = self.rate_scan_export(self.rate_export, print=False)
self.rate_export, self.rate_export_replicated = self.rate_replicate(self.rate_export, is_import=False)
# For export tariff only load the saving session if enabled
if self.rate_export_max > 0:
self.load_saving_slot(octopus_saving_slots, export=True, rate_replicate=self.rate_export_replicated)
if "rates_export_override" in self.args:
self.rate_export = self.basic_rates(self.get_arg("rates_export_override", [], indirect=False), "export", self.rate_export, self.rate_export_replicated)
self.rate_export = self.rate_scan_export(self.rate_export, print=True)
else:
self.log("Warning: No export rate data provided")
self.record_status(message="Error: No export rate data provided", had_errors=True)
# Set rate thresholds
if self.rate_import or self.rate_export:
self.set_rate_thresholds()
# Find discharging windows
if self.rate_export:
self.high_export_rates, lowest, highest = self.rate_scan_window(self.rate_export, 5, self.rate_export_cost_threshold, True)
# Update threshold automatically
self.log("High export rate found rates in range {} to {}".format(lowest, highest))
if self.rate_high_threshold == 0 and lowest <= self.rate_export_max:
self.rate_export_cost_threshold = lowest
# Find charging windows
if self.rate_import:
# Find charging window
self.low_rates, lowest, highest = self.rate_scan_window(self.rate_import, 5, self.rate_import_cost_threshold, False)
self.log("Low Import rate found rates in range {} to {}".format(lowest, highest))
# Update threshold automatically
if self.rate_low_threshold == 0 and highest >= self.rate_min:
self.rate_import_cost_threshold = highest
# Work out car plan?
for car_n in range(self.num_cars):
if self.octopus_intelligent_charging and car_n == 0:
if self.car_charging_planned[car_n]:
self.log("Car 0 on Octopus Intelligent, active plan for charge")
else:
self.log("Car 0 on Octopus Intelligent, no active plan")
elif self.car_charging_planned[car_n] or self.car_charging_now[car_n]:
self.log(
"Car {} plan charging from {} to {} with slots {} from soc {} to {} ready by {}".format(
car_n,
self.car_charging_soc[car_n],
self.car_charging_limit[car_n],
self.low_rates,
self.car_charging_soc[car_n],
self.car_charging_limit[car_n],
self.car_charging_plan_time[car_n],
)
)
self.car_charging_slots[car_n] = self.plan_car_charging(car_n, self.low_rates)
else:
self.log("Car {} charging is not planned as it is not plugged in".format(car_n))
# Log the charging plan
if self.car_charging_slots[car_n]:
self.log("Car {} charging plan is: {}".format(car_n, self.car_charging_slots[car_n]))
if self.car_charging_planned[car_n] and self.car_charging_exclusive[car_n]:
self.log("Car {} charging is exclusive, will not plan other cars".format(car_n))
break
# Publish the car plan
self.publish_car_plan()
# Work out iboost plan
if self.iboost_enable and (((not self.iboost_solar) and (not self.iboost_charging)) or self.iboost_smart):
self.iboost_plan = self.plan_iboost_smart()
self.log(
"IBoost iboost_solar {} rate threshold import {} rate threshold export {} iboost_gas {} iboost_gas_export {} iboost_smart {} plan is: {}".format(
self.iboost_solar,
self.iboost_rate_threshold,
self.iboost_rate_threshold_export,
self.iboost_gas,
self.iboost_gas_export,
self.iboost_smart,
self.iboost_plan,
)
)
# Work out cost today
if self.import_today:
self.cost_today_sofar, self.carbon_today_sofar = self.today_cost(self.import_today, self.export_today)
# Fetch PV forecast if enabled, today must be enabled, other days are optional
self.pv_forecast_minute, self.pv_forecast_minute10 = self.fetch_pv_forecast()
# Apply modal filter to historical data
if self.load_minutes and not self.load_forecast_only:
self.previous_days_modal_filter(self.load_minutes)
self.log("Historical days now {} weight {}".format(self.days_previous, self.days_previous_weight))
# Load today vs actual
if self.load_minutes:
self.load_inday_adjustment = self.load_today_comparison(self.load_minutes, self.load_forecast, self.car_charging_energy, self.import_today, self.minutes_now)
else:
self.load_inday_adjustment = 1.0
def publish_rate_and_threshold(self):
"""
Publish energy rate data and thresholds
"""
# Find discharging windows
if self.rate_export:
if self.rate_best_cost_threshold_discharge:
self.rate_export_cost_threshold = self.rate_best_cost_threshold_discharge
self.log("Export threshold used for optimisation was {}p".format(self.rate_export_cost_threshold))
self.publish_rates(self.rate_export, True)
# Find charging windows
if self.rate_import:
if self.rate_best_cost_threshold_charge:
self.rate_import_cost_threshold = self.rate_best_cost_threshold_charge
self.log("Import threshold used for optimisation was {}p".format(self.rate_import_cost_threshold))
self.publish_rates(self.rate_import, False)
# And gas
if self.rate_gas:
self.publish_rates(self.rate_gas, False, gas=True)
def fetch_inverter_data(self):
"""
Fetch data about the inverters
"""
# Find the inverters
self.num_inverters = int(self.get_arg("num_inverters", 1))
self.inverter_limit = 0.0
self.export_limit = 0.0
self.inverters = []
self.charge_window = []
self.discharge_window = []
self.discharge_limits = []
self.current_charge_limit = 0.0
self.soc_kw = 0.0
self.soc_max = 0.0
self.reserve = 0.0
self.reserve_percent = 0.0
self.reserve_current = 0.0
self.reserve_current_percent = 0.0
self.battery_rate_max_charge = 0.0
self.battery_rate_max_discharge = 0.0
self.battery_rate_max_charge_scaled = 0.0
self.battery_rate_max_discharge_scaled = 0.0
self.battery_rate_min = 0
self.charge_rate_now = 0.0
self.discharge_rate_now = 0.0
self.pv_power = 0
self.load_power = 0
found_first = False
# For each inverter get the details
for id in range(self.num_inverters):
inverter = Inverter(self, id)
inverter.update_status(self.minutes_now)
if id == 0 and (not self.computed_charge_curve or self.battery_charge_power_curve_auto) and not self.battery_charge_power_curve:
curve = inverter.find_charge_curve(discharge=False)
if curve and self.battery_charge_power_curve_auto:
self.log("Saved computed battery charge power curve")
self.battery_charge_power_curve = curve
self.computed_charge_curve = True
if id == 0 and (not self.computed_discharge_curve or self.battery_discharge_power_curve_auto) and not self.battery_discharge_power_curve:
curve = inverter.find_charge_curve(discharge=True)
if curve and self.battery_discharge_power_curve_auto:
self.log("Saved computed battery discharge power curve")
self.battery_discharge_power_curve = curve
self.computed_discharge_curve = True
# As the inverters will run in lockstep, we will initially look at the programming of the first enabled one for the current window setting
if not found_first:
found_first = True
self.current_charge_limit = inverter.current_charge_limit
self.charge_window = inverter.charge_window
self.discharge_window = inverter.discharge_window
self.discharge_limits = inverter.discharge_limits
if not inverter.inv_support_discharge_freeze:
# Force off unsupported feature
self.log("Note: Inverter does not support discharge freeze - disabled")
self.set_discharge_freeze = False
self.set_discharge_freeze_only = False
if not inverter.inv_support_charge_freeze:
# Force off unsupported feature
self.log("Note: Inverter does not support charge freeze - disabled")
self.set_charge_freeze = False
if not inverter.inv_has_reserve_soc:
self.log("Note: Inverter does not support reserve - disabling reserve functions")
self.set_reserve_enable = False
self.set_reserve_hold = False
self.set_discharge_during_charge = True
self.soc_max += inverter.soc_max
self.soc_kw += inverter.soc_kw
self.reserve += inverter.reserve
self.reserve_current += inverter.reserve_current
self.battery_rate_max_charge += inverter.battery_rate_max_charge
self.battery_rate_max_discharge += inverter.battery_rate_max_discharge
self.battery_rate_max_charge_scaled += inverter.battery_rate_max_charge_scaled
self.battery_rate_max_discharge_scaled += inverter.battery_rate_max_discharge_scaled
self.charge_rate_now += inverter.charge_rate_now
self.discharge_rate_now += inverter.discharge_rate_now
self.battery_rate_min += inverter.battery_rate_min
self.inverters.append(inverter)
self.inverter_limit += inverter.inverter_limit
self.export_limit += inverter.export_limit
self.pv_power += inverter.pv_power
self.load_power += inverter.load_power
# Remove extra decimals
self.soc_max = self.dp2(self.soc_max)
self.soc_kw = self.dp2(self.soc_kw)
self.reserve = self.dp2(self.reserve)
self.reserve_percent = calc_percent_limit(self.reserve, self.soc_max)
self.reserve_current = self.dp2(self.reserve_current)
self.reserve_current_percent = calc_percent_limit(self.reserve_current, self.soc_max)
self.log(
"Found {} inverters totals: min reserve {} current reserve {} soc_max {} soc {} charge rate {} kW discharge rate {} kW battery_rate_min {} w ac limit {} export limit {} kW loss charge {} % loss discharge {} % inverter loss {} %".format(
len(self.inverters),
self.reserve,
self.reserve_current,
self.soc_max,
self.soc_kw,
self.charge_rate_now * 60,
self.discharge_rate_now * 60,
self.battery_rate_min * MINUTE_WATT,
self.dp2(self.inverter_limit * 60),
self.dp2(self.export_limit * 60),
100 - int(self.battery_loss * 100),
100 - int(self.battery_loss_discharge * 100),
100 - int(self.inverter_loss * 100),
)
)
# Work out current charge limits and publish charge limit base
self.charge_limit = [self.current_charge_limit * self.soc_max / 100.0 for i in range(len(self.charge_window))]
self.charge_limit_percent = calc_percent_limit(self.charge_limit, self.soc_max)
self.publish_charge_limit(self.charge_limit, self.charge_window, self.charge_limit_percent, best=False)
self.log("Base charge window {}".format(self.window_as_text(self.charge_window, self.charge_limit_percent)))
self.log("Base discharge window {}".format(self.window_as_text(self.discharge_window, self.discharge_limits)))
async def async_manual_select(self, config_item, value):
"""
Async wrapper for selection on manual times dropdown
"""
return await self.run_in_executor(self.manual_select, config_item, value)
def manual_select(self, config_item, value):
"""
Selection on manual times dropdown
"""
item = self.config_index.get(config_item)
if not item:
return
if not value:
# Ignore null selections
return
if value.startswith("+"):
# Ignore selections which are just the current value
return
values = item.get("value", "")
if not values:
values = ""
values = values.replace("+", "")
values_list = []
exclude_list = []
if values:
values_list = values.split(",")
if value == "off":
values_list = []
elif "[" in value:
value = value.replace("[", "")
value = value.replace("]", "")
if value in values_list:
values_list.remove(value)
else:
if value not in values_list:
values_list.append(value)
exclude_list.append(value)
item_value = ",".join(values_list)
if item_value:
item_value = "+" + item_value
if not item_value:
item_value = "off"
self.manual_times(config_item, new_value=item_value)
# Update other drop downs that may need this time excluding
for item in CONFIG_ITEMS:
if item["name"] != config_item and item.get("manual"):
value = item.get("value", "")
if value and value != "reset" and exclude_list:
self.manual_times(item["name"], exclude=exclude_list)
def manual_times(self, config_item, exclude=[], new_value=None):
"""
Update manual times sensor
"""
time_overrides = []
minutes_now = int(self.minutes_now / 30) * 30
manual_time_max = 18 * 60
# Deconstruct the value into a list of minutes
item = self.config_index.get(config_item)
if new_value:
values = new_value
else:
values = item.get("value", "")
values = values.replace("+", "")
values_list = []
if values:
values_list = values.split(",")
for value in values_list:
if value == "off":
continue
try:
start_time = datetime.strptime(value, "%H:%M:%S")
except ValueError:
start_time = None
if start_time:
minutes = start_time.hour * 60 + start_time.minute
if minutes < minutes_now:
minutes += 24 * 60
if (minutes - minutes_now) < manual_time_max:
time_overrides.append(minutes)
# Reconstruct the list in order based on minutes
values_list = []
for minute in time_overrides:
minute_str = (self.midnight + timedelta(minutes=minute)).strftime("%H:%M:%S")
if minute_str not in exclude:
values_list.append(minute_str)
values = ",".join(values_list)
if values:
values = "+" + values
# Create the new dropdown
time_values = []
for minute in range(minutes_now, minutes_now + manual_time_max, 30):
minute_str = (self.midnight + timedelta(minutes=minute)).strftime("%H:%M:%S")
if minute in time_overrides:
minute_str = "[" + minute_str + "]"
time_values.append(minute_str)
if values not in time_values:
time_values.append(values)
time_values.append("off")
item["options"] = time_values
if not values:
values = "off"
self.expose_config(config_item, values, force=True)
if time_overrides:
time_txt = []
for minute in time_overrides:
time_txt.append(self.time_abs_str(minute))
return time_overrides
def fetch_config_options(self):
"""
Fetch all the configuration options
"""
self.debug_enable = self.get_arg("debug_enable")
self.previous_status = self.get_state_wrapper(self.prefix + ".status")
forecast_hours = max(self.get_arg("forecast_hours", 48), 24)
self.num_cars = self.get_arg("num_cars", 1)
self.calculate_plan_every = max(self.get_arg("calculate_plan_every"), 5)
self.log(
"Configuration: forecast_hours {} num_cars {} debug enable is {} calculate_plan_every {}".format(
forecast_hours, self.num_cars, self.debug_enable, self.calculate_plan_every
)
)
# Days previous
self.holiday_days_left = self.get_arg("holiday_days_left")
self.load_forecast_only = self.get_arg("load_forecast_only", False)
self.days_previous = self.get_arg("days_previous", [7])
self.days_previous_weight = self.get_arg("days_previous_weight", [1 for i in range(len(self.days_previous))])
if len(self.days_previous) > len(self.days_previous_weight):
# Extend weights with 1 if required
self.days_previous_weight += [1 for i in range(len(self.days_previous) - len(self.days_previous_weight))]
if self.holiday_days_left > 0:
self.days_previous = [1]
self.log("Holiday mode is active, {} days remaining, setting days previous to 1".format(self.holiday_days_left))
self.max_days_previous = max(self.days_previous) + 1
self.forecast_days = int((forecast_hours + 23) / 24)
self.forecast_minutes = forecast_hours * 60
self.forecast_plan_hours = max(min(self.get_arg("forecast_plan_hours", 96), forecast_hours), 8)
self.inverter_clock_skew_start = self.get_arg("inverter_clock_skew_start", 0)
self.inverter_clock_skew_end = self.get_arg("inverter_clock_skew_end", 0)
self.inverter_clock_skew_discharge_start = self.get_arg("inverter_clock_skew_discharge_start", 0)
self.inverter_clock_skew_discharge_end = self.get_arg("inverter_clock_skew_discharge_end", 0)
# Log clock skew
if self.inverter_clock_skew_start != 0 or self.inverter_clock_skew_end != 0:
self.log("Inverter clock skew start {} end {} applied".format(self.inverter_clock_skew_start, self.inverter_clock_skew_end))
if self.inverter_clock_skew_discharge_start != 0 or self.inverter_clock_skew_discharge_end != 0:
self.log("Inverter clock skew discharge start {} end {} applied".format(self.inverter_clock_skew_discharge_start, self.inverter_clock_skew_discharge_end))
# Metric config
self.metric_min_improvement = self.get_arg("metric_min_improvement")
self.metric_min_improvement_discharge = self.get_arg("metric_min_improvement_discharge")
self.metric_battery_cycle = self.get_arg("metric_battery_cycle")
self.metric_self_sufficiency = self.get_arg("metric_self_sufficiency")
self.metric_future_rate_offset_import = self.get_arg("metric_future_rate_offset_import")
self.metric_future_rate_offset_export = self.get_arg("metric_future_rate_offset_export")
self.metric_inday_adjust_damping = self.get_arg("metric_inday_adjust_damping")
self.rate_low_threshold = self.get_arg("rate_low_threshold")
self.rate_high_threshold = self.get_arg("rate_high_threshold")
self.inverter_soc_reset = self.get_arg("inverter_soc_reset")
self.metric_battery_value_scaling = self.get_arg("metric_battery_value_scaling")
self.notify_devices = self.get_arg("notify_devices", ["notify"])
self.pv_scaling = self.get_arg("pv_scaling")
self.pv_metric10_weight = self.get_arg("pv_metric10_weight")
self.load_scaling = self.get_arg("load_scaling")
self.load_scaling10 = self.get_arg("load_scaling10")
self.load_scaling_saving = self.get_arg("load_scaling_saving")
self.battery_rate_max_scaling = self.get_arg("battery_rate_max_scaling")
self.battery_rate_max_scaling_discharge = self.get_arg("battery_rate_max_scaling_discharge")
self.best_soc_step = 0.25
self.metric_cloud_enable = self.get_arg("metric_cloud_enable")
self.metric_load_divergence_enable = self.get_arg("metric_load_divergence_enable")
# Battery charging options
self.battery_capacity_nominal = self.get_arg("battery_capacity_nominal")
self.battery_loss = 1.0 - self.get_arg("battery_loss")
self.battery_loss_discharge = 1.0 - self.get_arg("battery_loss_discharge")
self.inverter_loss = 1.0 - self.get_arg("inverter_loss")
self.inverter_hybrid = self.get_arg("inverter_hybrid")
# Charge curve
if self.args.get("battery_charge_power_curve", "") == "auto":
self.battery_charge_power_curve_auto = True
else:
self.battery_charge_power_curve_auto = False
self.battery_charge_power_curve = self.args.get("battery_charge_power_curve", {})
# Check power curve is a dictionary
if not isinstance(self.battery_charge_power_curve, dict):
self.battery_charge_power_curve = {}
self.log("Warn: battery_charge_power_curve is incorrectly configured - ignoring")
self.record_status("battery_charge_power_curve is incorrectly configured - ignoring", had_errors=True)
# Discharge curve
if self.args.get("battery_discharge_power_curve", "") == "auto":
self.battery_discharge_power_curve_auto = True
else:
self.battery_discharge_power_curve_auto = False
self.battery_discharge_power_curve = self.args.get("battery_discharge_power_curve", {})
# Check power curve is a dictionary
if not isinstance(self.battery_discharge_power_curve, dict):
self.battery_discharge_power_curve = {}
self.log("Warn: battery_discharge_power_curve is incorrectly configured - ignoring")
self.record_status("battery_discharge_power_curve is incorrectly configured - ignoring", had_errors=True)
self.import_export_scaling = self.get_arg("import_export_scaling", 1.0)
self.best_soc_margin = 0.0
self.best_soc_min = self.get_arg("best_soc_min")
self.best_soc_max = self.get_arg("best_soc_max")
self.best_soc_keep = self.get_arg("best_soc_keep")
self.set_soc_minutes = 30
self.set_window_minutes = 30
self.inverter_set_charge_before = self.get_arg("inverter_set_charge_before")
if not self.inverter_set_charge_before:
self.set_soc_minutes = 0
self.set_window_minutes = 0
self.octopus_intelligent_charging = self.get_arg("octopus_intelligent_charging")
self.octopus_intelligent_ignore_unplugged = self.get_arg("octopus_intelligent_ignore_unplugged")
self.get_car_charging_planned()
self.load_inday_adjustment = 1.0
self.combine_rate_threshold = self.get_arg("combine_rate_threshold")
self.combine_discharge_slots = self.get_arg("combine_discharge_slots")
self.combine_charge_slots = self.get_arg("combine_charge_slots")
self.charge_slot_split = 30
self.discharge_slot_split = 30
self.calculate_best = True
self.set_read_only = self.get_arg("set_read_only")
# hard wired options, can be configured per inverter later on
self.set_soc_enable = True
self.set_reserve_enable = self.get_arg("set_reserve_enable")
self.set_reserve_hold = True
self.set_discharge_freeze = True
self.set_charge_freeze = self.get_arg("set_charge_freeze")
self.set_charge_low_power = self.get_arg("set_charge_low_power")
self.calculate_discharge_first = True
self.set_status_notify = self.get_arg("set_status_notify")
self.set_inverter_notify = self.get_arg("set_inverter_notify")
self.set_discharge_freeze_only = self.get_arg("set_discharge_freeze_only")
self.set_discharge_during_charge = self.get_arg("set_discharge_during_charge")
# Mode
self.predbat_mode = self.get_arg("mode")
if self.predbat_mode == PREDBAT_MODE_OPTIONS[PREDBAT_MODE_CONTROL_SOC]:
self.calculate_best_charge = True
self.calculate_best_discharge = False
self.set_charge_window = False
self.set_discharge_window = False
self.set_soc_enable = True
elif self.predbat_mode == PREDBAT_MODE_OPTIONS[PREDBAT_MODE_CONTROL_CHARGE]:
self.calculate_best_charge = True
self.calculate_best_discharge = False
self.set_charge_window = True
self.set_discharge_window = False
self.set_soc_enable = True
elif self.predbat_mode == PREDBAT_MODE_OPTIONS[PREDBAT_MODE_CONTROL_CHARGEDISCHARGE]:
self.calculate_best_charge = True
self.calculate_best_discharge = True
self.set_charge_window = True
self.set_discharge_window = True
self.set_soc_enable = True
else: # PREDBAT_MODE_OPTIONS[PREDBAT_MODE_MONITOR]
self.calculate_best_charge = False
self.calculate_best_discharge = False
self.set_charge_window = False
self.set_discharge_window = False
self.predbat_mode = PREDBAT_MODE_OPTIONS[PREDBAT_MODE_MONITOR]
self.set_soc_enable = False
self.expose_config("mode", self.predbat_mode)
self.log("Predbat mode is set to {}".format(self.predbat_mode))
self.calculate_discharge_oncharge = self.get_arg("calculate_discharge_oncharge")
self.calculate_second_pass = self.get_arg("calculate_second_pass")
self.calculate_inday_adjustment = self.get_arg("calculate_inday_adjustment")
self.calculate_tweak_plan = self.get_arg("calculate_tweak_plan")
self.calculate_regions = True
self.calculate_secondary_order = self.get_arg("calculate_secondary_order")
self.balance_inverters_enable = self.get_arg("balance_inverters_enable")
self.balance_inverters_charge = self.get_arg("balance_inverters_charge")
self.balance_inverters_discharge = self.get_arg("balance_inverters_discharge")
self.balance_inverters_crosscharge = self.get_arg("balance_inverters_crosscharge")
self.balance_inverters_threshold_charge = max(self.get_arg("balance_inverters_threshold_charge"), 1.0)
self.balance_inverters_threshold_discharge = max(self.get_arg("balance_inverters_threshold_discharge"), 1.0)
if self.set_read_only:
self.log("NOTE: Read-only mode is enabled, the inverter controls will not be used!!")
# Enable load filtering
self.load_filter_modal = self.get_arg("load_filter_modal")
# Calculate savings
self.calculate_savings = True
# Carbon
self.carbon_enable = self.get_arg("carbon_enable")
self.carbon_metric = self.get_arg("carbon_metric")
# iBoost solar diverter model
self.iboost_enable = self.get_arg("iboost_enable")
self.iboost_gas = self.get_arg("iboost_gas")
self.iboost_gas_export = self.get_arg("iboost_gas_export")
self.iboost_smart = self.get_arg("iboost_smart")
self.iboost_on_discharge = self.get_arg("iboost_on_discharge")
self.iboost_prevent_discharge = self.get_arg("iboost_prevent_discharge")
self.iboost_solar = self.get_arg("iboost_solar")
self.iboost_rate_threshold = self.get_arg("iboost_rate_threshold")
self.iboost_rate_threshold_export = self.get_arg("iboost_rate_threshold_export")
self.iboost_charging = self.get_arg("iboost_charging")
self.iboost_gas_scale = self.get_arg("iboost_gas_scale")
self.iboost_max_energy = self.get_arg("iboost_max_energy")
self.iboost_max_power = self.get_arg("iboost_max_power") / MINUTE_WATT
self.iboost_min_power = self.get_arg("iboost_min_power") / MINUTE_WATT
self.iboost_min_soc = self.get_arg("iboost_min_soc")
self.iboost_today = self.get_arg("iboost_today")
self.iboost_value_scaling = self.get_arg("iboost_value_scaling")
self.iboost_energy_subtract = self.get_arg("iboost_energy_subtract")
self.iboost_next = self.iboost_today
self.iboost_running = False
self.iboost_running_solar = False
self.iboost_running_full = False
self.iboost_energy_today = {}
self.iboost_plan = []
# Car options
self.car_charging_hold = self.get_arg("car_charging_hold")
self.car_charging_manual_soc = self.get_arg("car_charging_manual_soc")
self.car_charging_threshold = float(self.get_arg("car_charging_threshold")) / 60.0
self.car_charging_energy_scale = self.get_arg("car_charging_energy_scale")
# Update list of slot times
self.manual_charge_times = self.manual_times("manual_charge")
self.manual_discharge_times = self.manual_times("manual_discharge")
self.manual_freeze_charge_times = self.manual_times("manual_freeze_charge")
self.manual_freeze_discharge_times = self.manual_times("manual_freeze_discharge")
self.manual_idle_times = self.manual_times("manual_idle")
self.manual_all_times = (
self.manual_charge_times + self.manual_discharge_times + self.manual_idle_times + self.manual_freeze_charge_times + self.manual_freeze_discharge_times
)
# Update list of config options to save/restore to
self.update_save_restore_list()
def update_time(self, print=True):
"""
Update the current time/date
"""
local_tz = pytz.timezone(self.args.get("timezone", "Europe/London"))
skew = self.args.get("clock_skew", 0)
if skew:
self.log("Warn: Clock skew is set to {} minutes".format(skew))
self.now_utc_real = datetime.now(local_tz)
now_utc = self.now_utc_real + timedelta(minutes=skew)
now = datetime.now() + timedelta(minutes=skew)
now = now.replace(second=0, microsecond=0, minute=(now.minute - (now.minute % PREDICT_STEP)))
now_utc = now_utc.replace(second=0, microsecond=0, minute=(now_utc.minute - (now_utc.minute % PREDICT_STEP)))
self.now_utc = now_utc
self.now = now
self.midnight = now.replace(hour=0, minute=0, second=0, microsecond=0)
self.midnight_utc = now_utc.replace(hour=0, minute=0, second=0, microsecond=0)
self.difference_minutes = self.minutes_since_yesterday(now)
self.minutes_now = int((now - self.midnight).seconds / 60 / PREDICT_STEP) * PREDICT_STEP
self.minutes_to_midnight = 24 * 60 - self.minutes_now
self.log("--------------- PredBat - update at {} with clock skew {} minutes, minutes now {}".format(now_utc, skew, self.minutes_now))
def update_pred(self, scheduled=True):
"""
Update the prediction state, everything is called from here right now
"""
status_extra = ""
self.had_errors = False
self.dashboard_index = []
self.update_time()
self.save_current_config()
self.expose_config("active", True)
# Check our version
self.download_predbat_releases()
self.fetch_config_options()
self.fetch_sensor_data()
self.fetch_inverter_data()
recompute = False
if not scheduled or not self.plan_valid:
self.log("Will recompute the plan as it is invalid")
recompute = True
else:
plan_age = self.now_utc - self.plan_last_updated
plan_age_minutes = plan_age.seconds / 60.0
self.log("Plan was last updated on {} and is now {} minutes old".format(self.plan_last_updated, self.dp1(plan_age_minutes)))
# Calculate the new plan (or re-use existing)
recompute = self.calculate_plan(recompute=recompute)
# Publish rate data
self.publish_rate_and_threshold()
# Execute the plan, re-read the inverter first if we had to calculate (as time passes during calculations)
if recompute:
self.fetch_inverter_data()
status, status_extra = self.execute_plan()
# If the plan was not updated, and the time has expired lets update it now
if not recompute:
plan_age = self.now_utc - self.plan_last_updated
plan_age_minutes = plan_age.seconds / 60.0
if (plan_age_minutes + RUN_EVERY) > self.calculate_plan_every:
self.log(
"Will recompute the plan as it is now {} minutes old and will exceed the max age of {} minutes before the next run".format(
self.dp1(plan_age_minutes), self.calculate_plan_every
)
)
# Calculate an updated plan, fetch the inverter data again and execute the plan
self.calculate_plan(recompute=True)
self.fetch_inverter_data()
status, status_extra = self.execute_plan()
else:
self.log("Will not recompute the plan, it is {} minutes old and max age is {} minutes".format(self.dp1(plan_age_minutes), self.calculate_plan_every))
# iBoost solar diverter model update state, only on 5 minute intervals
if self.iboost_enable and scheduled:
if self.iboost_energy_today:
# If we have a realtime sensor just use that data
self.iboost_next = self.iboost_today
elif recompute and (self.minutes_now >= 0) and (self.minutes_now < self.calculate_plan_every):
# Reset at midnight
self.iboost_next = 0
# Save next iBoost model value
self.expose_config("iboost_today", self.iboost_next)
self.log("iBoost model today updated to {}".format(self.iboost_next))
if self.calculate_savings:
# Get current totals
savings_total_predbat = self.load_previous_value_from_ha(self.prefix + ".savings_total_predbat")
try:
savings_total_predbat = float(savings_total_predbat)
except (ValueError, TypeError):
savings_total_predbat = 0.0
savings_total_pvbat = self.load_previous_value_from_ha(self.prefix + ".savings_total_pvbat")
try:
savings_total_pvbat = float(savings_total_pvbat)
except (ValueError, TypeError):
savings_total_pvbat = 0.0
savings_total_soc = self.load_previous_value_from_ha(self.prefix + ".savings_total_soc")
try:
savings_total_soc = float(savings_total_soc)
except (ValueError, TypeError):
savings_total_soc = 0.0
savings_total_actual = self.load_previous_value_from_ha(self.prefix + ".savings_total_actual")
try:
savings_total_actual = float(savings_total_actual)
except (ValueError, TypeError):
savings_total_actual = 0.0
# Increment total at midnight for next day
if (self.minutes_now >= 0) and (self.minutes_now < self.calculate_plan_every) and scheduled and recompute:
savings_total_predbat += self.savings_today_predbat
savings_total_pvbat += self.savings_today_pvbat
savings_total_soc = self.savings_today_predbat_soc
savings_total_actual += self.savings_today_actual
self.dashboard_item(
self.prefix + ".savings_total_predbat",
state=self.dp2(savings_total_predbat),
attributes={
"friendly_name": "Total Predbat savings",
"state_class": "measurement",
"unit_of_measurement": "p",
"pounds": self.dp2(savings_total_predbat / 100.0),
"icon": "mdi:cash-multiple",
},
)
self.dashboard_item(
self.prefix + ".savings_total_soc",
state=self.dp2(savings_total_soc),
attributes={
"friendly_name": "Predbat savings, yesterday SOC",
"state_class": "measurement",
"unit_of_measurement": "kWh",
"icon": "mdi:battery-50",
},
)
self.dashboard_item(
self.prefix + ".savings_total_actual",
state=self.dp2(savings_total_actual),
attributes={
"friendly_name": "Actual total energy cost",
"state_class": "measurement",
"unit_of_measurement": "p",
"pounds": self.dp2(savings_total_actual / 100.0),
"icon": "mdi:cash-multiple",
},
)
self.dashboard_item(
self.prefix + ".savings_total_pvbat",
state=self.dp2(savings_total_pvbat),
attributes={
"friendly_name": "Total Savings vs no PV/Battery system",
"state_class": "measurement",
"unit_of_measurement": "p",
"pounds": self.dp2(savings_total_pvbat / 100.0),
"icon": "mdi:cash-multiple",
},
)
# Car SOC increment
if scheduled:
for car_n in range(self.num_cars):
if (car_n == 0) and self.car_charging_manual_soc:
self.log("Car charging Manual SOC current is {} next is {}".format(self.car_charging_soc[car_n], self.car_charging_soc_next[car_n]))
if self.car_charging_soc_next[car_n] is not None:
self.expose_config("car_charging_manual_soc_kwh", round(self.car_charging_soc_next[car_n], 3))
# Holiday days left countdown, subtract a day at midnight every day
if scheduled and self.holiday_days_left > 0 and self.minutes_now < RUN_EVERY:
self.holiday_days_left -= 1
self.expose_config("holiday_days_left", self.holiday_days_left)
self.log("Holiday days left is now {}".format(self.holiday_days_left))
if self.debug_enable:
self.create_debug_yaml()
if self.had_errors:
self.log("Completed run status {} with Errors reported (check log)".format(status))
else:
self.log("Completed run status {}".format(status))
self.record_status(
status,
debug="best_charge_limit={} best_charge_window={} best_discharge_limit= {} best_discharge_window={}".format(
self.charge_limit_best, self.charge_window_best, self.discharge_limits_best, self.discharge_window_best
),
notify=True,
extra=status_extra,
)
self.expose_config("active", False)
self.save_current_config()
async def update_event(self, event, data, kwargs):
"""
Update event.
This function is called when an update event is triggered. It handles the logic for updating the application.
Parameters:
- event (str): The name of the event that triggered the update.
- data (dict): Additional data associated with the update event.
- kwargs (dict): Additional keyword arguments passed to the update event.
Returns:
None
"""
self.log("Update event {} {} {}".format(event, data, kwargs))
if data and data.get("service", "") == "install":
service_data = data.get("service_data", {})
if service_data.get("entity_id", "") == "update.predbat_version":
latest = self.releases.get("latest", None)
if latest:
self.log("Requested install of latest version {}".format(latest))
await self.async_download_predbat_version(latest)
elif data and data.get("service", "") == "skip":
self.log("Requested to skip the update, this is not yet supported...")
async def async_download_predbat_version(self, version):
"""
Sync wrapper for async download_predbat_version
"""
return await self.run_in_executor(self.download_predbat_version, version)
def download_predbat_version(self, version):
"""
Download a version of Predbat from GitHub
Args:
version (str): The version of Predbat to download.
Returns:
bool: True if the download and update were successful, False otherwise.
"""
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)
files = predbat_update_download(version)
if files:
# 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...")
try:
self.pool.close()
self.pool.join()
except Exception as e:
self.log("Warn: Failed to close thread pool: {}".format(e))
self.log("Warn: " + traceback.format_exc())
self.pool = None
# Notify that we are about to update
self.call_notify("Predbat: update to: {}".format(version))
# Perform the update
self.log("Perform the update.....")
if predbat_update_move(version, files):
self.log("Update to version {} completed".format(version))
return True
else:
self.log("Warn: Predbat update failed to move Predbat version {}".format(version))
return False
else:
self.log("Warn: Predbat update failed to download Predbat version {}".format(version))
return False
async def select_event(self, event, data, kwargs):
"""
Catch HA Input select updates
Parameters:
- event: The event triggered by the input select.
- data: The data associated with the event.
- kwargs: Additional keyword arguments.
Returns:
None
Description:
This method is used to handle Home Assistant input select updates.
It extracts the necessary information from the data and performs different actions based on the selected option.
The actions include calling update service, saving and restoring settings, performing manual selection, and exposing configuration.
After performing the actions, it triggers an update by setting update_pending flag to True and plan_valid flag to False.
"""
service_data = data.get("service_data", {})
value = service_data.get("option", None)
entities = service_data.get("entity_id", [])
# Can be a string or an array
if isinstance(entities, str):
entities = [entities]
for item in CONFIG_ITEMS:
if ("entity" in item) and (item["entity"] in entities):
entity = item["entity"]
self.log("select_event: {}, {} = {}".format(item["name"], entity, value))
if item["name"] == "update":
self.log("Calling update service for {}".format(value))
await self.async_download_predbat_version(value)
elif item["name"] == "saverestore":
if value == "save current":
await self.async_update_save_restore_list()
await self.async_save_settings_yaml()
elif value == "restore default":
await self.async_restore_settings_yaml(None)
else:
await self.async_restore_settings_yaml(value)
elif item.get("manual"):
await self.async_manual_select(item["name"], value)
else:
await self.async_expose_config(item["name"], value, event=True)
self.update_pending = True
self.plan_valid = False
async def number_event(self, event, data, kwargs):
"""
Catch HA Input number updates
This method is called when there is an update to a Home Assistant input number entity.
It extracts the value and entity ID from the event data and processes it accordingly.
If the entity ID matches any of the entities specified in the CONFIG_ITEMS list,
it logs the entity and value, exposes the configuration item, and updates the pending plan.
Args:
event (str): The event name.
data (dict): The event data.
kwargs (dict): Additional keyword arguments.
Returns:
None
"""
service_data = data.get("service_data", {})
value = service_data.get("value", None)
entities = service_data.get("entity_id", [])
# Can be a string or an array
if isinstance(entities, str):
entities = [entities]
for item in CONFIG_ITEMS:
if ("entity" in item) and (item["entity"] in entities):
entity = item["entity"]
self.log("number_event: {} = {}".format(entity, value))
await self.async_expose_config(item["name"], value, event=True)
self.update_pending = True
self.plan_valid = False
async def watch_event(self, entity, attribute, old, new, kwargs):
"""
Catch HA state changes for watched entities
"""
self.log("Watched event: {} = {} will trigger re-plan".format(entity, new))
self.update_pending = True
self.plan_valid = False
async def switch_event(self, event, data, kwargs):
"""
Catch HA Switch toggle
This method is called when a Home Assistant switch is toggled. It handles the logic for updating the state of the switch
and triggering any necessary actions based on the switch state.
Parameters:
- event (str): The event triggered by the switch toggle.
- data (dict): Additional data associated with the event.
- kwargs (dict): Additional keyword arguments.
Returns:
- None
"""
service = data.get("service", None)
service_data = data.get("service_data", {})
entities = service_data.get("entity_id", [])
# Can be a string or an array
if isinstance(entities, str):
entities = [entities]
for item in CONFIG_ITEMS:
if ("entity" in item) and (item["entity"] in entities):
value = item["value"]
entity = item["entity"]
if service == "turn_on":
value = True
elif service == "turn_off":
value = False
elif service == "toggle" and isinstance(value, bool):
value = not value
self.log("switch_event: {} = {}".format(entity, value))
await self.async_expose_config(item["name"], value, event=True)
self.update_pending = True
self.plan_valid = False
def get_ha_config(self, name, default):
"""
Get Home assistant config value, use default if not set
Parameters:
name (str): The name of the config value to retrieve.
default: The default value to use if the config value is not set.
Returns:
value: The value of the config if it is set, otherwise the default value.
default: The default value passed as an argument.
"""
item = self.config_index.get(name)
if item and item["name"] == name:
value = item.get("value", None)
if default is None:
default = item.get("default", None)
if value is None:
value = default
return value, default
return None, default
async def async_expose_config(self, name, value, quiet=True, event=False, force=False, in_progress=False):
return await self.run_in_executor(self.expose_config, name, value, quiet, event, force, in_progress)
def expose_config(self, name, value, quiet=True, event=False, force=False, in_progress=False, force_ha=False):
"""
Share the config with HA
"""
item = self.config_index.get(name, None)
if item:
enabled = self.user_config_item_enabled(item)
if not enabled:
self.log("Not updating HA config {} to {} as disabled".format(name, value))
item["value"] = None
else:
entity = item.get("entity")
has_changed = ((item.get("value", None) is None) or (value != item.get("value", None))) or force
if entity and (has_changed or force_ha):
if has_changed and item.get("reset_inverter", False):
self.inverter_needs_reset = True
self.log("Set reset inverter true due to reset_inverter on item {}".format(name))
if has_changed and item.get("reset_inverter_force", False):
self.inverter_needs_reset = True
self.log("Set reset inverter true due to reset_inverter_force on item {}".format(name))
if event:
self.inverter_needs_reset_force = name
self.log("Set reset inverter force true due to reset_inverter_force on item {}".format(name))
item["value"] = value
if not quiet:
self.log("Updating HA config {} to {}".format(name, value))
if item["type"] == "input_number":
"""INPUT_NUMBER"""
icon = item.get("icon", "mdi:numeric")
unit = item["unit"]
unit = unit.replace("£", self.currency_symbols[0])
unit = unit.replace("p", self.currency_symbols[1])
self.set_state_wrapper(
entity_id=entity,
state=value,
attributes={
"friendly_name": item["friendly_name"],
"min": item["min"],
"max": item["max"],
"step": item["step"],
"unit_of_measurement": unit,
"icon": icon,
},
)
elif item["type"] == "switch":
"""SWITCH"""
icon = item.get("icon", "mdi:light-switch")
self.set_state_wrapper(entity_id=entity, state=("on" if value else "off"), attributes={"friendly_name": item["friendly_name"], "icon": icon})
elif item["type"] == "select":
"""SELECT"""
icon = item.get("icon", "mdi:format-list-bulleted")
if value is None:
value = item.get("default", "")
options = item["options"]
if value not in options:
options.append(value)
old_state = self.get_state_wrapper(entity_id=entity)
if old_state and old_state != value:
self.set_state_wrapper(entity_id=entity, state=old_state, attributes={"friendly_name": item["friendly_name"], "options": options, "icon": icon})
self.set_state_wrapper(entity_id=entity, state=value, attributes={"friendly_name": item["friendly_name"], "options": options, "icon": icon})
elif item["type"] == "update":
"""UPDATE"""
summary = self.releases.get("latest_body", "")
latest = self.releases.get("latest", "check HACS")
state = "off"
if item["installed_version"] != latest:
state = "on"
self.set_state_wrapper(
entity_id=entity,
state=state,
attributes={
"friendly_name": item["friendly_name"],
"title": item["title"],
"in_progress": in_progress,
"auto_update": True,
"installed_version": item["installed_version"],
"latest_version": latest,
"entity_picture": item["entity_picture"],
"release_url": item["release_url"],
"release_summary": summary,
"skipped_version": None,
"supported_features": 1,
},
)
def user_config_item_enabled(self, item):
"""
Check if user config item is enable
"""
enable = item.get("enable", None)
if enable:
citem = self.config_index.get(enable, None)
if citem:
enabled_value = citem.get("value", True)
if not enabled_value:
return False
else:
return True
else:
self.log("Warn: Badly formed CONFIG enable item {}, please raise a Github ticket".format(item["name"]))
return True
def dashboard_item(self, entity, state, attributes):
"""
Publish state and log dashboard item
"""
self.set_state_wrapper(entity_id=entity, state=state, attributes=attributes)
if entity not in self.dashboard_index:
self.dashboard_index.append(entity)
async def async_update_save_restore_list(self):
return await self.run_in_executor(self.update_save_restore_list)
def update_save_restore_list(self):
"""
Update list of current Predbat settings
"""
global PREDBAT_SAVE_RESTORE
self.save_restore_dir = self.config_root + "/predbat_save"
if not os.path.exists(self.save_restore_dir):
os.mkdir(self.save_restore_dir)
PREDBAT_SAVE_RESTORE = ["save current", "restore default"]
for root, dirs, files in os.walk(self.save_restore_dir):
for name in files:
filepath = os.path.join(root, name)
if filepath.endswith(".yaml") and not name.startswith("."):
PREDBAT_SAVE_RESTORE.append(name)
item = self.config_index.get("saverestore", None)
item["options"] = PREDBAT_SAVE_RESTORE
self.expose_config("saverestore", None)
async def async_restore_settings_yaml(self, filename):
"""
Restore settings from YAML file
"""
self.save_restore_dir = self.config_root + "/predbat_save"
# Create full hierarchical version of filepath to write to the logfile
filepath_p = self.config_root_p + "/predbat_save"
if filename != "previous.yaml":
await self.async_save_settings_yaml("previous.yaml")
if not filename:
self.log("Restore settings to default")
for item in CONFIG_ITEMS:
if (item["value"] != item.get("default", None)) and item.get("restore", True):
self.log("Restore setting: {} = {} (was {})".format(item["name"], item["default"], item["value"]))
await self.async_expose_config(item["name"], item["default"], event=True)
await self.async_call_notify("Predbat settings restored from default")
else:
filepath = os.path.join(self.save_restore_dir, filename)
if os.path.exists(filepath):
filepath_p = filepath_p + "/" + filename
self.log("Restore settings from {}".format(filepath_p))
with open(filepath, "r") as file:
settings = yaml.safe_load(file)
for item in settings:
current = self.config_index.get(item["name"], None)
if current and (current["value"] != item["value"]) and current.get("restore", True):
self.log("Restore setting: {} = {} (was {})".format(item["name"], item["value"], current["value"]))
await self.async_expose_config(item["name"], item["value"], event=True)
await self.async_call_notify("Predbat settings restored from {}".format(filename))
await self.async_expose_config("saverestore", None)
def load_current_config(self):
"""
Load the current configuration from a json file
"""
filepath = self.config_root + "/predbat_config.json"
if os.path.exists(filepath):
with open(filepath, "r") as file:
try:
settings = json.load(file)
except json.JSONDecodeError:
self.log("Warn: Failed to load Predbat settings from {}".format(filepath))
return
for name in settings:
current = self.config_index.get(name, None)
if current:
item_value = settings[name]
if current.get("value", None) != item_value:
self.log("Restore saved setting: {} = {} (was {})".format(name, item_value, current.get("value", None)))
current["value"] = item_value
def save_current_config(self):
"""
Saves the currently defined configuration to a json file
"""
filepath = self.config_root + "/predbat_config.json"
# Create full hierarchical version of filepath to write to the logfile
filepath_p = self.config_root_p + "/predbat_config.json"
save_array = {}
for item in CONFIG_ITEMS:
if item.get("save", True):
if item.get("value", None) is not None:
save_array[item["name"]] = item["value"]
with open(filepath, "w") as file:
json.dump(save_array, file)
self.log("Saved current settings to {}".format(filepath_p))
async def async_save_settings_yaml(self, filename=None):
"""
Save current Predbat settings
"""
self.save_restore_dir = self.config_root + "/predbat_save"
filepath_p = self.config_root_p + "/predbat_save"
if not filename:
filename = self.now_utc.strftime("%y_%m_%d_%H_%M_%S")
filename += ".yaml"
filepath = os.path.join(self.save_restore_dir, filename)
filepath_p = filepath_p + "/" + filename
with open(filepath, "w") as file:
yaml.dump(CONFIG_ITEMS, file)
self.log("Saved Predbat settings to {}".format(filepath_p))
await self.async_call_notify("Predbat settings saved to {}".format(filename))
def create_debug_yaml(self):
"""
Write out a debug info yaml
"""
time_now = self.now_utc.strftime("%H_%M_%S")
basename = "/debug/predbat_debug_{}.yaml".format(time_now)
filename = self.config_root + basename
# Create full hierarchical version of filepath to write to the logfile
filename_p = self.config_root_p + basename
os.makedirs(os.path.dirname(filename), exist_ok=True)
debug = {}
debug["TIME"] = self.time_now_str()
debug["THIS_VERSION"] = THIS_VERSION
debug["CONFIG_ITEMS"] = CONFIG_ITEMS
debug["args"] = self.args
debug["charge_window_best"] = self.charge_window_best
debug["charge_limit_best"] = self.charge_limit_best
debug["discharge_window_best"] = self.discharge_window_best
debug["discharge_limits_best"] = self.discharge_limits_best
debug["low_rates"] = self.low_rates
debug["high_export_rates"] = self.high_export_rates
debug["load_forecast"] = self.load_forecast
debug["load_minutes_step"] = self.load_minutes_step
debug["load_minutes_step10"] = self.load_minutes_step10
debug["pv_forecast_minute_step"] = self.pv_forecast_minute_step
debug["pv_forecast_minute10_step"] = self.pv_forecast_minute10_step
debug["yesterday_load_step"] = self.yesterday_load_step
debug["yesterday_pv_step"] = self.yesterday_pv_step
with open(filename, "w") as file:
yaml.dump(debug, file)
self.log("Wrote debug yaml to {}".format(filename_p))
def create_entity_list(self):
"""
Create the standard entity list
"""
text = ""
text += "# Predbat Dashboard - {}\n".format(THIS_VERSION)
text += "type: entities\n"
text += "Title: Predbat\n"
text += "entities:\n"
enable_list = [None]
for item in CONFIG_ITEMS:
enable = item.get("enable", None)
if enable and enable not in enable_list:
enable_list.append(enable)
for try_enable in enable_list:
for item in CONFIG_ITEMS:
entity = item.get("entity", None)
enable = item.get("enable", None)
if entity and enable == try_enable and self.user_config_item_enabled(item):
text += " - entity: " + entity + "\n"
for entity in self.dashboard_index:
text += " - entity: " + entity + "\n"
# Find path
basename = "/predbat_dashboard.yaml"
filename = self.config_root + basename
# Create full hierarchical version of filepath to write to the logfile
filename_p = self.config_root_p + basename
# Write
han = open(filename, "w")
if han:
self.log("Creating predbat dashboard at {}".format(filename_p))
han.write(text)
han.close()
else:
self.log("Failed to write predbat dashboard to {}".format(filename_p))
def load_previous_value_from_ha(self, entity):
"""
Load HA value either from state or from history if there is any
"""
ha_value = self.get_state_wrapper(entity)
if ha_value is not None:
return ha_value
history = self.get_history_wrapper(entity_id=entity)
if history:
history = history[0]
ha_value = history[-1]["state"]
return ha_value
async def trigger_watch_list(self, entity_id, attribute, old, new):
"""
Trigger a watch event for an entity
"""
for entity in self.watch_list:
if entity_id == entity:
await self.watch_event(entity, attribute, old, new, None)
async def trigger_callback(self, service_data):
"""
Trigger a callback for a service via HA Interface
"""
for item in self.EVENT_LISTEN_LIST:
if item["domain"] == service_data.get("domain", "") and item["service"] == service_data.get("service", ""):
await item["callback"](item["service"], service_data, None)
def define_service_list(self):
self.SERVICE_REGISTER_LIST = [
{"domain": "input_number", "service": "set_value"},
{"domain": "input_number", "service": "increment"},
{"domain": "input_number", "service": "decrement"},
{"domain": "switch", "service": "turn_on"},
{"domain": "switch", "service": "turn_off"},
{"domain": "switch", "service": "toggle"},
{"domain": "select", "service": "select_option"},
{"domain": "select", "service": "select_first"},
{"domain": "select", "service": "select_last"},
{"domain": "select", "service": "select_next"},
{"domain": "select", "service": "select_previous"},
]
self.EVENT_LISTEN_LIST = [
{"domain": "switch", "service": "turn_on", "callback": self.switch_event},
{"domain": "switch", "service": "turn_off", "callback": self.switch_event},
{"domain": "switch", "service": "toggle", "callback": self.switch_event},
{"domain": "input_number", "service": "set_value", "callback": self.number_event},
{"domain": "input_number", "service": "increment", "callback": self.number_event},
{"domain": "input_number", "service": "decrement", "callback": self.number_event},
{"domain": "select", "service": "select_option", "callback": self.select_event},
{"domain": "select", "service": "select_first", "callback": self.select_event},
{"domain": "select", "service": "select_last", "callback": self.select_event},
{"domain": "select", "service": "select_next", "callback": self.select_event},
{"domain": "select", "service": "select_previous", "callback": self.select_event},
{"domain": "update", "service": "install", "callback": self.update_event},
{"domain": "update", "service": "skip", "callback": self.update_event},
]
def load_user_config(self, quiet=True, register=False):
"""
Load config from HA
"""
self.config_index = {}
self.log("Refreshing Predbat configuration")
# New install, used to set default of expert mode
new_install = True
current_status = self.load_previous_value_from_ha(self.prefix + ".status")
if current_status:
new_install = False
else:
self.log("New install detected")
# Build config index
for item in CONFIG_ITEMS:
name = item["name"]
self.config_index[name] = item
if name == "mode" and new_install:
item["default"] = PREDBAT_MODE_OPTIONS[PREDBAT_MODE_MONITOR]
# Load current config (if there is one)
if register:
self.log("Loading current config")
self.load_current_config()
# Find values and monitor config
for item in CONFIG_ITEMS:
name = item["name"]
type = item["type"]
enabled = self.user_config_item_enabled(item)
entity = type + "." + self.prefix + "_" + name
item["entity"] = entity
ha_value = None
if not enabled:
if not quiet:
self.log("Note: Disabled configuration item {}".format(name))
item["value"] = None
# Remove the state if the entity still exists
ha_value = self.get_state_wrapper(entity)
if ha_value is not None:
self.set_state_wrapper(entity_id=entity, state=ha_value, attributes={"friendly_name": "[Disabled] " + item["friendly_name"]})
continue
# Get from current state, if not from HA directly
ha_value = item.get("value", None)
if ha_value is None:
ha_value = self.load_previous_value_from_ha(entity)
# Update drop down menu
if name == "update":
if not ha_value:
# Construct this version information as it's not set correctly already
ha_value = THIS_VERSION + " Loading..."
else:
# Leave current value until it's set during version discovery later
continue
# Default?
if ha_value is None:
ha_value = item.get("default", None)
# Switch convert to text
if type == "switch" and isinstance(ha_value, str):
if ha_value.lower() in ["on", "true", "enable"]:
ha_value = True
else:
ha_value = False
if type == "input_number" and ha_value is not None:
try:
ha_value = float(ha_value)
except (ValueError, TypeError):
ha_value = None
if type == "update":
ha_value = None
# Push back into current state
if ha_value is not None:
if item.get("manual"):
self.manual_times(name, new_value=ha_value)
else:
self.expose_config(item["name"], ha_value, quiet=quiet, force_ha=True)
# Update the last time we refreshed the config
self.set_state_wrapper(entity_id=self.prefix + ".config_refresh", state=self.now_utc.strftime(TIME_FORMAT))
# Register HA services
if register:
self.watch_list = self.get_arg("watch_list", [], indirect=False)
self.log("Watch list {}".format(self.watch_list))
if not self.ha_interface.websocket_active:
# Registering HA events as Websocket is not active
for item in self.SERVICE_REGISTER_LIST:
self.fire_event("service_registered", domain=item["domain"], service=item["service"])
for item in self.EVENT_LISTEN_LIST:
self.listen_select_handle = self.listen_event(item["callback"], event="call_service", domain=item["domain"], service=item["service"])
for entity in self.watch_list:
if entity and isinstance(entity, str) and ("." in entity):
self.listen_state(self.watch_event, entity_id=entity)
# Save current config to file if it was pending
self.save_current_config()
def resolve_arg_re(self, arg, arg_value, state_keys):
"""
Resolve argument regular expression on list or string
"""
matched = True
if isinstance(arg_value, list):
new_list = []
for item_value in arg_value:
item_matched, item_value = self.resolve_arg_re(arg, item_value, state_keys)
if not item_matched:
self.log("Warn: Regular argument {} expression {} failed to match - disabling this item".format(arg, item_value))
new_list.append(None)
else:
new_list.append(item_value)
arg_value = new_list
elif isinstance(arg_value, dict):
new_dict = {}
for item_name in arg_value:
item_value = arg_value[item_name]
item_matched, item_value = self.resolve_arg_re(arg, item_value, state_keys)
if not item_matched:
self.log("Warn: Regular argument {} expression {} failed to match - disabling this item".format(arg, item_value))
new_dict[item_name] = None
else:
new_dict[item_name] = item_value
arg_value = new_dict
elif isinstance(arg_value, str) and arg_value.startswith("re:"):
matched = False
my_re = "^" + arg_value[3:] + "$"
for key in state_keys:
res = re.search(my_re, key)
if res:
if len(res.groups()) > 0:
self.log("Regular expression argument {} matched {} with {}".format(arg, my_re, res.group(1)))
arg_value = res.group(1)
matched = True
break
else:
self.log("Regular expression argument {} Matched {} with {}".format(arg, my_re, res.group(0)))
arg_value = res.group(0)
matched = True
break
return matched, arg_value
def auto_config(self):
"""
Auto configure
match arguments with sensors
"""
states = self.get_state_wrapper()
state_keys = states.keys()
disabled = []
if 0:
predbat_keys = []
for key in state_keys:
if "predbat" in str(key):
predbat_keys.append(key)
predbat_keys.sort()
self.log("Keys:\n - entity: {}".format("\n - entity: ".join(predbat_keys)))
# Find each arg re to match
for arg in self.args:
arg_value = self.args[arg]
matched, arg_value = self.resolve_arg_re(arg, arg_value, state_keys)
if not matched:
self.log("Warn: Regular expression argument: {} unable to match {}, now will disable".format(arg, arg_value))
disabled.append(arg)
else:
self.args[arg] = arg_value
# Remove unmatched keys
for key in disabled:
del self.args[key]
def sanity(self):
"""
Sanity check AppDaemon setup
"""
self.log("Sanity check:")
config_dir = ""
passed = True
app_dirs = []
config_dir = self.config_root
self.log("Sanity scan files in '{}' : {}".format(config_dir, os.listdir(config_dir)))
app_dir = config_dir + "/apps"
appdaemon_config = config_dir + "/appdaemon.yaml"
appdaemon_config_p = self.config_root_p + "/appdaemon.yaml"
if config_dir and os.path.exists(appdaemon_config):
with open(appdaemon_config, "r") as han:
data = None
try:
data = yaml.safe_load(han)
except yaml.YAMLError:
self.log("Error: Unable to read {} file correctly!".format(appdaemon_config_p))
passed = False
if data and ("appdaemon" in data):
sub_data = data["appdaemon"]
if "app_dir" in sub_data:
app_dir = sub_data["app_dir"]
if app_dir not in app_dirs:
app_dirs.append(app_dir)
self.log("Sanity: Got app_dir {}".format(app_dir))
elif data:
self.log("Warn: appdaemon section is missing from {}".format(appdaemon_config_p))
passed = False
else:
self.log("Warn: unable to find {} skipping checks as Predbat maybe running outside of AppDaemon".format(appdaemon_config_p))
return
self.log("Sanity: Scanning app_dirs: {}".format(app_dirs))
apps_yaml = []
predbat_py = []
for dir in app_dirs:
for root, dirs, files in os.walk(dir):
for name in files:
filepath = os.path.join(root, name)
if name == "apps.yaml":
self.log("Sanity: Got apps.yaml in location {}".format(filepath))
apps_yaml.append(filepath)
elif name == "predbat.py":
self.log("Sanity: Got predbat.py in location {}".format(filepath))
predbat_py.append(filepath)
if not apps_yaml:
self.log("Warn: Unable to find any apps.yaml files, please check your configuration")
passed = False
# Check apps.yaml to find predbat configuration
validPred = 0
for filename in apps_yaml:
with open(filename, "r") as han:
data = None
try:
data = yaml.safe_load(han)
except yaml.YAMLError:
self.log("Error: Unable to read YAML {} file correctly!".format(filename))
passed = False
if data and "pred_bat" in data:
self.log("Sanity: {} is a valid pred_bat configuration".format(filename))
validPred += 1
if not validPred:
self.log("Warn: Unable to find any valid Predbat configurations")
passed = False
if validPred > 1:
self.log("Warn: You have multiple valid Predbat configurations")
passed = False
# Check predbat.py
if not predbat_py:
self.log("Warn: Unable to find predbat.py, please check your configuration")
passed = False
elif len(predbat_py) > 1:
self.log("Warn: Found multiple predbat.py files, please check your configuration")
passed = False
else:
filename = predbat_py[0]
foundVersion = False
with open(filename, "r") as han:
for line in han:
if "THIS_VERSION" in line:
res = re.search('THIS_VERSION\s+=\s+"([0-9.v]+)"', line)
if res:
version = res.group(1)
foundVersion = True
if version != THIS_VERSION:
self.log("Warn: The version in predbat.py is {} but this code is version {} - please re-start Predbat".format(version, THIS_VERSION))
passed = False
else:
self.log("Sanity: Confirmed correct version {} is in predbat.py".format(version))
if not foundVersion:
self.log("Warn: Unable to find THIS_VERSION in Predbat.py file, please check your setup")
passed = False
if passed:
self.log("Sanity check has passed")
else:
self.log("Sanity check FAILED!")
self.record_status("Warn: Sanity startup checked has FAILED, see your logs for details")
return passed
def initialize(self):
"""
Setup the app, called once each time the app starts
"""
self.pool = None
self.log("Predbat: Startup {}".format(__name__))
self.update_time(print=False)
run_every = RUN_EVERY * 60
now = self.now
try:
self.reset()
self.ha_interface = HAInterface(self)
# Printable config root
self.config_root_p = self.config_root
slug = self.ha_interface.get_slug()
if slug:
# and use slug name to determine printable config_root pathname when writing debug info to the log file
self.config_root_p = "/addon_configs/" + slug
self.log("Config root is {} and printable config_root_p is now {}".format(self.config_root, self.config_root_p))
self.sanity()
self.ha_interface.update_states()
self.auto_config()
self.load_user_config(quiet=False, register=True)
except Exception as e:
self.log("Error: Exception raised {}".format(e))
self.log("Error: " + traceback.format_exc())
self.record_status("Error: Exception raised {}".format(e))
raise e
# Catch template configurations and exit
if self.get_arg("template", False):
self.log("Error: You still have a template configuration, please edit apps.yaml or restart AppDaemon if you just updated with HACS")
self.record_status("Error: You still have a template configuration, please edit apps.yaml or restart AppDaemon if you just updated with HACS")
# before terminating, create predbat dashboard for new users
try:
self.create_entity_list()
except Exception as e:
self.log("Error: Exception raised {}".format(e))
self.log("Error: " + traceback.format_exc())
self.record_status("Error: Exception raised {}".format(e))
raise e
return
# Run every N minutes aligned to the minute
seconds_now = (now - self.midnight).seconds
# Calculate next run time to exactly align with the run_every time
seconds_offset = seconds_now % run_every
seconds_next = seconds_now + (run_every - seconds_offset)
next_time = self.midnight + timedelta(seconds=seconds_next)
self.log("Predbat: Next run time will be {} and then every {} seconds".format(next_time, run_every))
# First run is now
self.update_pending = True
# And then every N minutes
if not INVERTER_TEST:
self.run_every(self.run_time_loop, next_time, run_every, random_start=0, random_end=0)
self.run_every(self.update_time_loop, datetime.now(), 15, random_start=0, random_end=0)
else:
self.update_time_loop(None)
# Balance inverters
run_every_balance = self.get_arg("balance_inverters_seconds", 60)
if run_every_balance > 0:
self.log("Balance inverters will run every {} seconds (if enabled)".format(run_every_balance))
seconds_offset_balance = seconds_now % run_every_balance
seconds_next_balance = seconds_now + (run_every_balance - seconds_offset_balance) + 15 # Offset to start after Predbat update task
next_time_balance = self.midnight + timedelta(seconds=seconds_next_balance)
self.run_every(self.run_time_loop_balance, next_time_balance, run_every_balance, random_start=0, random_end=0)
async def terminate(self):
"""
Called once each time the app terminates
"""
self.log("Predbat terminating")
self.stop_thread = True
await asyncio.sleep(0)
if hasattr(self, "pool"):
if self.pool:
try:
self.pool.close()
self.pool.join()
except Exception as e:
self.log("Warn: Failed to close thread pool {}".format(e))
self.log("Warn: " + traceback.format_exc())
self.pool = None
self.log("Predbat terminated")
def update_time_loop(self, cb_args):
"""
Called every 15 seconds
"""
self.check_entity_refresh()
if self.update_pending and not self.prediction_started:
self.prediction_started = True
self.ha_interface.update_states()
self.load_user_config()
self.update_pending = False
try:
self.update_pred(scheduled=False)
self.create_entity_list()
except Exception as e:
self.log("Error: Exception raised {}".format(e))
self.log("Error: " + traceback.format_exc())
self.record_status("Error: Exception raised {}".format(e))
raise e
finally:
self.prediction_started = False
self.prediction_started = False
def check_entity_refresh(self):
"""
Check if we need to refresh the config entities with HA
"""
# Check if we need to refresh the config entities with HA
config_refresh = self.get_state_wrapper(entity_id=self.prefix + ".config_refresh")
config_refresh_stamp = None
if config_refresh:
try:
config_refresh_stamp = datetime.strptime(config_refresh, TIME_FORMAT)
except (ValueError, TypeError):
config_refresh_stamp = None
age = CONFIG_REFRESH_PERIOD
if config_refresh_stamp:
tdiff = self.now_utc - config_refresh_stamp
age = tdiff.seconds / 60 + tdiff.days * 60 * 24
if age >= CONFIG_REFRESH_PERIOD:
self.log("Info: Refresh config entities due to their age of {} minutes".format(age))
self.update_pending = True
else:
self.log("Info: Refresh config entities as config_refresh state is unknown")
self.update_pending = True
def run_time_loop(self, cb_args):
"""
Called every N minutes
"""
if not self.prediction_started:
config_changed = False
self.prediction_started = True
self.ha_interface.update_states()
self.check_entity_refresh()
if self.update_pending:
self.load_user_config()
config_changed = True
self.update_pending = False
try:
self.update_pred(scheduled=True)
except Exception as e:
self.log("Error: Exception raised {}".format(e))
self.log("Error: " + traceback.format_exc())
self.record_status("Error: Exception raised {}".format(e))
raise e
finally:
self.prediction_started = False
if config_changed:
self.create_entity_list()
self.prediction_started = False
def run_time_loop_balance(self, cb_args):
"""
Called every N second for balance inverters
"""
if not self.prediction_started and self.balance_inverters_enable and not self.set_read_only:
try:
self.balance_inverters()
except Exception as e:
self.log("Error: Exception raised {}".format(e))
self.log("Error: " + traceback.format_exc())
self.record_status("Error: Exception raised {}".format(e))
raise e