From d18fe108c39ebae81fe9d555ec434e545200bf6e Mon Sep 17 00:00:00 2001 From: salewis38 <46199139+salewis38@users.noreply.github.com> Date: Sat, 19 Aug 2023 08:37:29 +0100 Subject: [PATCH 1/9] Align with PALM v1.1.0 --- GivTCP/palm_utils.py | 688 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 688 insertions(+) create mode 100644 GivTCP/palm_utils.py diff --git a/GivTCP/palm_utils.py b/GivTCP/palm_utils.py new file mode 100644 index 0000000..6424712 --- /dev/null +++ b/GivTCP/palm_utils.py @@ -0,0 +1,688 @@ +#!/usr/bin/env python3 +"""PALM - PV Active Load Manager.""" + +import time +import json +from datetime import datetime, timedelta +from typing import Tuple, List +import logging +## import matplotlib.pyplot as plt +import random +import requests +import palm_settings as stgs + +logger = logging.getLogger(__name__) + +# This software in any form is covered by the following Open Source BSD license: +# +# Copyright 2023, Steve Lewis +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without modification, are permitted +# provided that the following conditions are met: +# +# 1. Redistributions of source code must retain the above copyright notice, this list of conditions +# and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright notice, this list of +# conditions and the following disclaimer in the documentation and/or other materials provided +# with the distribution. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS +# OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY +# AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR +# CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, +# WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY +# WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +########################################### +# This code provides several functions: +# 1. Collection of generation/consumption data from GivEnergy API & upload to PVOutput +# 2. Load management - lights, excess power dumping, etc +# 3. Setting overnight charge point, based on SolCast forecast & actual usage +########################################### + +# Changelog: +# v0.6.0 12/Feb/22 First cut at GivEnergy interface +# ... +# v0.10.0 21/Jun/23 Added multi-day averaging for usage calcs +# v1.0.0 15/Jul/23 Random start time, Solcast data correction, IO compatibility, 48-hour fcast +# v1.1.0 06/Aug/23 Split out generic functions as palm_utils.py (this file) + +PALM_VERSION = "v1.1.0" +# -*- coding: utf-8 -*- +# pylint: disable=logging-not-lazy +# pylint: disable=consider-using-f-string + +class GivEnergyObj: + """Class for GivEnergy inverter""" + + def __init__(self): + sys_item = {'time': '', + 'solar': {'power': 0, 'arrays': + [{'array': 1, 'voltage': 0, 'current': 0, 'power': 0}, + {'array': 2, 'voltage': 0, 'current': 0, 'power': 0}]}, + 'grid': {'voltage': 0, 'current': 0, 'power': 0, 'frequency': 0}, + 'battery': {'percent': 0, 'power': 0, 'temperature': 0}, + 'inverter': {'temperature': 0, 'power': 0, 'output_voltage': 0, \ + 'output_frequency': 0, 'eps_power': 0}, + 'consumption': 0} + self.sys_status: List[str] = [sys_item] * 5 + + meter_item = {'time': '', + 'today': {'solar': 0, 'grid': {'import': 0, 'export': 0}, + 'battery': {'charge': 0, 'discharge': 0}, 'consumption': 0}, + 'total': {'solar': 0, 'grid': {'import': 0, 'export': 0}, + 'battery': {'charge': 0, 'discharge': 0}, 'consumption': 0}} + self.meter_status: List[str] = [meter_item] * 5 + + self.read_time_mins: int = -100 + self.line_voltage: float = 0 + self.grid_power: int = 0 + self.grid_energy: int = 0 + self.pv_power: int = 0 + self.pv_energy: int = 0 + self.batt_power: int = 0 + self.consumption: int = 0 + self.soc: int = 0 + self.base_load = stgs.GE.base_load + self.tgt_soc = 100 + self.cmd_list = stgs.GE_Command_list['data'] + self.plot = [""] * 5 + + logger.debug("Valid inverter commands:") + for line in self.cmd_list: + logger.debug(str(line['id'])+ "- "+ str(line['name'])) + + def get_latest_data(self): + """Download latest data from GivEnergy.""" + + utc_timenow_mins = t_to_mins(time.strftime("%H:%M:%S", time.gmtime())) + if (utc_timenow_mins > self.read_time_mins + 5 or + utc_timenow_mins < self.read_time_mins): # Update every 5 minutes plus day rollover + + url = stgs.GE.url + "system-data/latest" + key = stgs.GE.key + headers = { + 'Authorization': 'Bearer ' + key, + 'Content-Type': 'application/json', + 'Accept': 'application/json' + } + + try: + resp = requests.request('GET', url, headers=headers) + except requests.exceptions.RequestException as error: + logger.error(error) + return + + if len(resp.content) > 100: + for i in range(4, -1, -1): # Right shift old data + if i > 0: + self.sys_status[i] = self.sys_status[i - 1] + else: + try: + self.sys_status[i] = \ + json.loads(resp.content.decode('utf-8'))['data'] + except Exception: + logger.error("Error reading GivEnergy sys status "+ stgs.pg.t_now) + logger.error(resp.content) + self.sys_status[i] = self.sys_status[i + 1] + if stgs.pg.loop_counter == 0: # Pack array on startup + i = 1 + while i < 5: + self.sys_status[i] = self.sys_status[0] + i += 1 + self.read_time_mins = t_to_mins(self.sys_status[0]['time'][11:]) + self.line_voltage = float(self.sys_status[0]['grid']['voltage']) + self.grid_power = -1 * int(self.sys_status[0]['grid']['power']) # -ve = export + self.pv_power = int(self.sys_status[0]['solar']['power']) + self.batt_power = int(self.sys_status[0]['battery']['power']) # -ve = charging + self.consumption = int(self.sys_status[0]['consumption']) + self.soc = int(self.sys_status[0]['battery']['percent']) + + url = stgs.GE.url + "meter-data/latest" + try: + resp = requests.request('GET', url, headers=headers, timeout=10) + except requests.exceptions.RequestException as error: + logger.error(error) + return + + if len(resp.content) > 100: + for i in range(4, -1, -1): # Right shift old data + if i > 0: + self.meter_status[i] = self.meter_status[i - 1] + else: + try: + self.meter_status[i] = \ + json.loads(resp.content.decode('utf-8'))['data'] + except Exception: + logger.error("Error reading GivEnergy meter status "+ stgs.pg.t_now) + logger.error(resp.content) + self.meter_status[i] = self.meter_status[i + 1] + if stgs.pg.loop_counter == 0: # Pack array on startup + i = 1 + while i < 5: + self.meter_status[i] = self.meter_status[0] + i += 1 + + self.pv_energy = int(self.meter_status[0]['today']['solar'] * 1000) + + # Daily grid energy must be >=0 for PVOutput.org (battery charge >= midnight value) + self.grid_energy = max(int(self.meter_status[0]['today']['consumption'] * 1000), 0) + + def get_load_hist(self): + """Download historical consumption data from GivEnergy and pack array for next SoC calc""" + + def get_load_hist_day(offset: int): + """Get load history for a single day""" + + load_array = [0] * 48 + day_delta = offset if (stgs.pg.t_now_mins > 1260) else offset + 1 # Today if >9pm + day = datetime.strftime(datetime.now() - timedelta(day_delta), '%Y-%m-%d') + url = stgs.GE.url + "data-points/"+ day + key = stgs.GE.key + headers = { + 'Authorization': 'Bearer ' + key, + 'Content-Type': 'application/json', + 'Accept': 'application/json' + } + params = { + 'page': '1', + 'pageSize': '2000' + } + + try: + resp = requests.request('GET', url, headers=headers, params=params, timeout=10) + except requests.exceptions.RequestException as error: + logger.error(error) + return load_array + if resp.status_code != 200: + logger.error("Invalid response: "+ str(resp.status_code)) + return load_array + + if len(resp.content) > 100: + history = json.loads(resp.content.decode('utf-8')) + i = 6 + counter = 0 + current_energy = prev_energy = 0 + while i < 290: + try: + current_energy = float(history['data'][i]['today']['consumption']) + except Exception: + break + if counter == 0: + load_array[counter] = round(current_energy, 1) + else: + load_array[counter] = round(current_energy - prev_energy, 1) + counter += 1 + prev_energy = current_energy + i += 6 + return load_array + + load_hist_array = [0] * 48 + acc_load = [0] * 48 + total_weight: float = 0 + + i: int = 0 + while i < len(stgs.GE.load_hist_weight): + if stgs.GE.load_hist_weight[i] > 0: + logger.info("Processing load history for day -"+ str(i + 1)) + load_hist_array = get_load_hist_day(i) + j = 0 + while j < 48: + acc_load[j] += load_hist_array[j] * stgs.GE.load_hist_weight[i] + acc_load[j] = round(acc_load[j], 2) + j += 1 + total_weight += stgs.GE.load_hist_weight[i] + logger.debug(str(acc_load)+ " total weight: "+ str(total_weight)) + else: + logger.info("Skipping load history for day -"+ str(i + 1)+ " (weight <= 0)") + i += 1 + + # Avoid DIV/0 if config file contains incorrect weightings + if total_weight == 0: + logger.error("Configuration error: incorrect daily weightings") + total_weight = 1 + + # Calculate averages and write results + i = 0 + while i < 48: + self.base_load[i] = round(acc_load[i]/total_weight, 1) + i += 1 + logger.info("Load Calc Summary: "+ str(self.base_load)) + + def set_mode(self, cmd: str): + """Configures inverter operating mode""" + + def set_inverter_register(register: str, value: str): + """Exactly as it says""" + + # Validate command against list in settings + cmd_name = "" + valid_cmd = False + for line in self.cmd_list: + if line['id'] == int(register): + cmd_name = line['name'] + valid_cmd = True + break + + if valid_cmd is False: + logger.critical("write attempt to invalid inverter register: "+ str(register)) + return + + url = stgs.GE.url + "settings/"+ register + "/write" + key = stgs.GE.key + headers = { + 'Authorization': 'Bearer ' + key, + 'Content-Type': 'application/json', + 'Accept': 'application/json' + } + payload = { + 'value': value + } + resp = "TEST" + if not stgs.pg.test_mode: + try: + resp = requests.request('POST', url, headers=headers, json=payload, timeout=10) + except requests.exceptions.RequestException as error: + logger.error(error) + return + if resp.status_code != 201: + logger.info("Invalid response: "+ str(resp.status_code)) + return + + logger.info("Setting Register "+ str(register)+ " ("+ str(cmd_name) + ") to "+ + str(value)+ " Response: "+ str(resp)) + + time.sleep(3) # Allow data on GE server to settle + + # Readback check + url = stgs.GE.url + "settings/"+ register + "/read" + key = stgs.GE.key + headers = { + 'Authorization': 'Bearer ' + key, + 'Content-Type': 'application/json', + 'Accept': 'application/json' + } + payload = {} + + try: + resp = requests.request('POST', url, headers=headers, json=payload, timeout=10) + except resp.exceptions.RequestException as error: + logger.error(error) + return + if resp.status_code != 201: + logger.error("Invalid response: "+ str(resp.status_code)) + return + + returned_cmd = json.loads(resp.content.decode('utf-8'))['data']['value'] + if str(returned_cmd) == str(value): + logger.info("Successful register read: "+ str(register)+ " = "+ str(returned_cmd)) + else: + logger.error("Readback failed on GivEnergy API... Expected " + + str(value) + ", Read: "+ str(returned_cmd)) + + if cmd == "set_soc": # Sets target SoC to value, randomises start time to be grid friendly + set_inverter_register("77", str(self.tgt_soc)) + if stgs.GE.start_time != "": + start_time = t_to_hrs(t_to_mins(stgs.GE.start_time) + random.randint(1,14)) + set_inverter_register("64", start_time) + if stgs.GE.end_time != "": + set_inverter_register("65", stgs.GE.end_time) + + elif cmd == "set_soc_winter": # Restore default overnight charge params + set_inverter_register("77", "100") + if stgs.GE.start_time != "": + start_time = t_to_hrs(t_to_mins(stgs.GE.start_time) + random.randint(1,14)) + set_inverter_register("64", stgs.GE.start_time) + if stgs.GE.end_time_winter != "": + set_inverter_register("65", stgs.GE.end_time_winter) + + elif cmd == "charge_now": + set_inverter_register("77", "100") + set_inverter_register("64", "00:01") + set_inverter_register("65", "23:59") + + elif cmd == "pause": + set_inverter_register("72", "0") + set_inverter_register("73", "0") + + elif cmd == "resume": + set_inverter_register("72", "3000") + set_inverter_register("73", "3000") + + elif cmd == "test": + logger.debug("Test set_mode") + + else: + logger.error("unknown inverter command: "+ cmd) + + def compute_tgt_soc(self, gen_fcast, weight: int, commit: bool) -> str: + """Compute overnight SoC target""" + + # Winter months = 100% + if stgs.pg.month in stgs.GE.winter and commit: # No need for sums... + logger.info("winter month, SoC set to 100") + self.tgt_soc = 100 + return "set_soc_winter" + + # Quick check for valid generation data + if gen_fcast.pv_est50_day[0] == 0: + logger.error("Missing generation data, SoC set to 100") + self.tgt_soc = 100 + return "set_soc" + + # Solcast provides 3 estimates (P10, P50 and P90). Compute individual weighting + # factors for each of the 3 estimates from the weight input parameter, using a + # triangular approximation for simplicity + + weight = min(max(weight,10),90) # Range check + wgt_10 = max(0, 50 - weight) + if weight > 50: + wgt_50 = 90 - weight + else: + wgt_50 = weight - 10 + wgt_90 = max(0, weight - 50) + + logger.info("") + logger.info("{:<20} {:>10} {:>10} {:>10} {:>10} {:>10} {:>10}".format("SoC Calc;", + "Day", "Hour", "Charge", "Cons", "Gen", "SoC")) + + # Definitions for export of SoC forecast in chart form + plot_x = ["Time"] + plot_y1 = ["Calculated SoC"] + plot_y2 = ["Adjusted SoC"] + plot_y3 = ["Max"] + plot_y4 = ["Reserve"] + + if stgs.GE.end_time != "": + end_charge_period = int(stgs.GE.end_time[0:2]) * 2 + else: + end_charge_period = 8 + + batt_max_charge: float = stgs.GE.batt_max_charge + batt_charge: float = [0] * 98 + reserve_energy = batt_max_charge * stgs.GE.batt_reserve / 100 + max_charge_pcnt = [0] * 2 + min_charge_pcnt = [0] * 2 + + # The clever bit: + # Start with battery at reserve %. For each 30-minute slot of the coming day, calculate + # the battery charge based on forecast generation and historical usage. Capture values + # for maximum charge and also the minimum charge value at any time before the maximum. + + day = 0 + diff = 0 + while day < 2: # Repeat for tomorrow and next day + batt_charge[0] = max_charge = min_charge = reserve_energy + est_gen = 0 + i = 0 + while i < 48: + if i <= end_charge_period: # Battery is in AC Charge mode + total_load = 0 + batt_charge[i] = batt_charge[0] + else: + total_load = self.base_load[i] + est_gen = (gen_fcast.pv_est10_30[day*48 + i] * wgt_10 + + gen_fcast.pv_est50_30[day*48 + i] * wgt_50 + + gen_fcast.pv_est90_30[day*48 + i] * wgt_90) / (wgt_10 + wgt_50 + wgt_90) + batt_charge[i] = (batt_charge[i - 1] + + max(-1 * stgs.GE.charge_rate, + min(stgs.GE.charge_rate, (est_gen - total_load)))) + + # Capture min charge on lowest point on down-slope before charge reaches 100% + # or max charge if on an up slope after overnight charge + if (batt_charge[i] <= batt_charge[i - 1] and + max_charge < batt_max_charge): + min_charge = min(min_charge, batt_charge[i]) + elif i > end_charge_period: # Charging after overnight boost + max_charge = max(max_charge, batt_charge[i]) + + logger.info("{:<20} {:>10} {:>10} {:>10} {:>10} {:>10} {:>10}".format("SoC Calc;", + day, t_to_hrs(i * 30), round(batt_charge[i], 2), round(total_load, 2), + round(est_gen, 2), int(100 * batt_charge[i] / batt_max_charge))) + + plot_x.append(t_to_hrs((day*48 + i) * 30)) # Time + plot_y1.append(int(100 * batt_charge[i]/batt_max_charge)) # Baseline SoC line + plot_y3.append(100) # Upper limit line + plot_y4.append(stgs.GE.batt_reserve) # Lower limit line + + i += 1 + + max_charge_pcnt[day] = int(100 * max_charge / batt_max_charge) + min_charge_pcnt[day] = int(100 * min_charge / batt_max_charge) + + day += 1 + + # low_soc is the minimum SoC target. Provide more buffer capacity in shoulder months + # when load is likely to be more variable, e.g. heating + if stgs.pg.month in stgs.GE.shoulder: + low_soc = stgs.GE.max_soc_target + else: + low_soc = stgs.GE.min_soc_target + + # So we now have the four values of max & min charge for tomorrow & overmorrow + # Check if overmorrow is better than tomorrow and there is opportunity to reduce target + # to avoid residual charge at the end of the day in anticipation of a sunny day + if max_charge_pcnt[1] > 100 - low_soc > max_charge_pcnt[0]: + logger.info("Overmorrow correction enabled") + max_charge_pc = max_charge_pcnt[0] + (max_charge_pcnt[1] - 100) / 2 + else: + logger.info("Overmorrow correction not needed/enabled") + max_charge_pc = max_charge_pcnt[0] + min_charge_pc = min_charge_pcnt[0] + + # The really clever bit: reduce the target SoC to the greater of: + # The surplus above 100% for max_charge_pcnt + # The value needed to achieve the stated spare capacity at minimum charge point + # The preset minimum value + tgt_soc = max(100 - max_charge_pc, (low_soc - min_charge_pc), low_soc) + # Range check the resulting value + tgt_soc = int(min(tgt_soc, 100)) # Limit range to 100% + + # Produce y2 = adjusted plot + day = 0 + diff = tgt_soc + while day < 2: + i = 0 + while i < 48: + if day == 1 and i == 0: + diff = plot_y2[48] - plot_y1[49] + if plot_y1[day*48 + i + 1] + diff > 100: # Correct for SoC > 100% + diff = 100 - plot_y1[day*48 + i] + plot_y2.append(plot_y1[day*48 + i + 1] + diff) + i += 1 + day += 1 + + # Store plot data + self.plot[0] = str(plot_x) + self.plot[1] = str(plot_y1) + self.plot[2] = str(plot_y2) + self.plot[3] = str(plot_y3) + self.plot[4] = str(plot_y4) + + logger.info("{:<25} {:>10} {:>10} {:>10} {:>10} {:>10}".format("SoC Calc Summary;", + "Max Charge", "Min Charge", "Max %", "Min %", "Target SoC")) + logger.info("{:<25} {:>10} {:>10} {:>10} {:>10} {:>10}".format("SoC Calc Summary;", + round(max_charge, 2), round(min_charge, 2), + max_charge_pc, min_charge_pc, tgt_soc)) + logger.info("{:<25} {:>10} {:>10} {:>10} {:>10} {:>10}".format("SoC (Adjusted);", + round(max_charge, 2), round(min_charge, 2), + max_charge_pc + tgt_soc, min_charge_pc + tgt_soc, "\n")) + + if commit: + self.tgt_soc = tgt_soc + return "set_soc" + + return "test" + +# End of GivEnergyObj() class definition + +class SolcastObj: + """Stores daily Solcast data.""" + + def __init__(self): + # Skeleton solcast summary array + self.pv_est10_day: [int] = [0] * 7 + self.pv_est50_day: [int] = [0] * 7 + self.pv_est90_day: [int] = [0] * 7 + + self.pv_est10_30: [int] = [0] * 96 + self.pv_est50_30: [int] = [0] * 96 + self.pv_est90_30: [int] = [0] * 96 + + def update(self): + """Updates forecast generation from Solcast.""" + + def get_solcast(url) -> Tuple[bool, str]: + """Download latest Solcast forecast.""" + + solcast_url = url + stgs.Solcast.cmd + "&api_key="+ stgs.Solcast.key + try: + resp = requests.get(solcast_url, timeout=5) + resp.raise_for_status() + except requests.exceptions.RequestException as error: + logger.error(error) + return False, "" + if resp.status_code != 200: + logger.error("Invalid response: "+ str(resp.status_code)) + return False, "" + + if len(resp.content) < 50: + logger.warning("Warning: Solcast data missing/short") + logger.warning(resp.content) + return False, "" + + solcast_data = json.loads(resp.content.decode('utf-8')) + logger.debug(str(solcast_data)) + + return True, solcast_data + # End of get_solcast() + + # Download latest data for each array, abort if unsuccessful + result, solcast_data_1 = get_solcast(stgs.Solcast.url_se) + if not result: + logger.warning("Error; Problem with Solcast data, using previous values (if any)") + return + + if stgs.Solcast.url_sw != "": # Two arrays are specified + logger.info("url_sw = '"+str(stgs.Solcast.url_sw)+"'") + result, solcast_data_2 = get_solcast(stgs.Solcast.url_sw) + if not result: + logger.warning("Error; Problem with Solcast data, using previous values (if any)") + return + else: + logger.info("No second array") + + logger.info("Successful Solcast download.") + + # Combine forecast for PV arrays & align data with day boundaries + pv_est10 = [0] * 10080 + pv_est50 = [0] * 10080 + pv_est90 = [0] * 10080 + + if stgs.Solcast.url_sw != "": # Two arrays are specified + forecast_lines = min(len(solcast_data_1['forecasts']), len(solcast_data_2['forecasts'])) - 1 + else: + forecast_lines = len(solcast_data_1['forecasts']) - 1 + interval = int(solcast_data_1['forecasts'][0]['period'][2:4]) + solcast_offset = t_to_mins(solcast_data_1['forecasts'][0]['period_end'][11:16]) - interval - 60 + + # Check for BST and convert to local time to align with GivEnergy data + if time.strftime("%z", time.localtime()) == "+0100": + logger.info("Applying BST offset to Solcast data") + solcast_offset += 60 + + i = solcast_offset + cntr = 0 + while i < solcast_offset + forecast_lines * interval: + try: + pv_est10[i] = int(solcast_data_1['forecasts'][cntr]['pv_estimate10'] * 1000) + pv_est50[i] = int(solcast_data_1['forecasts'][cntr]['pv_estimate'] * 1000) + pv_est90[i] = int(solcast_data_1['forecasts'][cntr]['pv_estimate90'] * 1000) + except Exception: + logger.error("Error: Unexpected end of Solcast data (array #1). i="+ \ + str(i)+ "cntr="+ str(cntr)) + break + + if i > 1 and i % interval == 0: + cntr += 1 + i += 1 + + if stgs.Solcast.url_sw != "": # Two arrays are specified + i = solcast_offset + cntr = 0 + while i < solcast_offset + forecast_lines * interval: + try: + pv_est10[i] += int(solcast_data_2['forecasts'][cntr]['pv_estimate10'] * 1000) + pv_est50[i] += int(solcast_data_2['forecasts'][cntr]['pv_estimate'] * 1000) + pv_est90[i] += int(solcast_data_2['forecasts'][cntr]['pv_estimate90'] * 1000) + except Exception: + logger.error("Error: Unexpected end of Solcast data (array #2). i="+ \ + str(i)+ "cntr="+ str(cntr)) + break + + if i > 1 and i % interval == 0: + cntr += 1 + i += 1 + + if solcast_offset > 720: # Forget about current day as it's already afternoon + offset = 1440 - 90 + else: + offset = 0 + + i = 0 + while i < 7: # Summarise daily forecasts + start = i * 1440 + offset + 1 + end = start + 1439 + self.pv_est10_day[i] = round(sum(pv_est10[start:end]) / 60000, 3) + self.pv_est50_day[i] = round(sum(pv_est50[start:end]) / 60000, 3) + self.pv_est90_day[i] = round(sum(pv_est90[start:end]) / 60000, 3) + i += 1 + + i = 0 + while i < 96: # Calculate half-hourly generation + start = i * 30 + offset + 1 + end = start + 29 + self.pv_est10_30[i] = round(sum(pv_est10[start:end])/60000, 3) + self.pv_est50_30[i] = round(sum(pv_est50[start:end])/60000, 3) + self.pv_est90_30[i] = round(sum(pv_est90[start:end])/60000, 3) + i += 1 + + timestamp = time.strftime("%d-%m-%Y %H:%M:%S", time.localtime()) + logger.info("PV Estimate 10% (hrly, 7 days) / kWh; "+ timestamp+ "; "+ + str(self.pv_est10_30[0:47])+ str(self.pv_est10_day[0:6])) + logger.info("PV Estimate 50% (hrly, 7 days) / kWh; "+ timestamp+ "; "+ + str(self.pv_est50_30[0:47])+ str(self.pv_est50_day[0:6])) + logger.info("PV Estimate 90% (hrly, 7 days) / kWh; "+ timestamp+ "; "+ + str(self.pv_est90_30[0:47])+ str(self.pv_est90_day[0:6])) + +# End of SolcastObj() class definition + +def t_to_mins(time_in_hrs: str) -> int: + """Convert times from HH:MM format to mins after midnight.""" + + try: + time_in_mins = 60 * int(time_in_hrs[0:2]) + int(time_in_hrs[3:5]) + return time_in_mins + except Exception: + return 0 + +# End of t_to_mins() + +def t_to_hrs(time_in: int) -> str: + """Convert times from mins after midnight format to HH:MM.""" + + try: + hours = int(time_in // 60) + mins = int(time_in - hours * 60) + time_in_hrs = '{:02d}{}{:02d}'.format(hours, ":", mins) + return time_in_hrs + except Exception: + return "00:00" + +# End of t_to_hrs() + +# End of palm_utils From dadbc90b920d8d1a20c3a9b5784314328820da7e Mon Sep 17 00:00:00 2001 From: salewis38 <46199139+salewis38@users.noreply.github.com> Date: Sat, 19 Aug 2023 08:40:01 +0100 Subject: [PATCH 2/9] Align with PALM v1.1.0 --- GivTCP/palm_settings.py | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/GivTCP/palm_settings.py b/GivTCP/palm_settings.py index 60e99a6..d0b6906 100644 --- a/GivTCP/palm_settings.py +++ b/GivTCP/palm_settings.py @@ -1,12 +1,27 @@ -# version 2023.06.21 -# Settings file for use with palm.py: 30-minute calculations (v0.9.0) and weightings for daily historical consumption (v0.10.0) - +# version 2023.08.07 +""" +Settings file for use with palm.py: Compatible with v0.9, v0.10, v1.0.x and v1.1.x +""" from settings import GiV_Settings from GivLUT import GivLUT import pickle from os.path import exists import os +class pg: + """PALM global variable definitions. Used by palm_utils and project-specific wrappers""" + + test_mode: bool = False + debug_mode: bool = False + once_mode: bool = False + long_t_now: str = "" + month: str = "" + t_now: str = "" + t_now_mins: int = 0 + loop_counter: int = 0 # 1 minute minor frame. "0" = initialise + pvo_tstamp: int = 0 # Records value of loop_counter when PV data last written + palm_version: str = "" + # User settings for GivEnergy inverter API class GE: enable = True From 2121fa889dc1cdb97f6983d791543e75adee97b7 Mon Sep 17 00:00:00 2001 From: salewis38 <46199139+salewis38@users.noreply.github.com> Date: Sat, 19 Aug 2023 08:41:03 +0100 Subject: [PATCH 3/9] Align with PALM v1.1.0 --- GivTCP/palm_soc.py | 762 +++++---------------------------------------- 1 file changed, 82 insertions(+), 680 deletions(-) diff --git a/GivTCP/palm_soc.py b/GivTCP/palm_soc.py index 4a9a3d3..fb47184 100644 --- a/GivTCP/palm_soc.py +++ b/GivTCP/palm_soc.py @@ -1,18 +1,17 @@ #!/usr/bin/env python3 """PALM - PV Active Load Manager.""" - -import time -import json -from datetime import datetime, timedelta -from typing import Tuple, List -from os.path import exists -import pickle -import requests import palm_settings as stgs -import write as wr -from GivLUT import GivLUT, GivQueue -logger = GivLUT.logger +from palm_utils import GivEnergyObj, SolcastObj, t_to_mins + +# Debug switch (if True) is used to run palm_Soc outside the HA environment for test purpses +DEBUG_SW = False +if DEBUG_SW: + import logging + import time +else: + import write as wr # pylint: disable=import-error + from GivLUT import GivLUT, GivQueue # pylint: disable=import-error # This software in any form is covered by the following Open Source BSD license: # @@ -45,682 +44,85 @@ # Changelog: # v0.8.3aSoC 28/Jul/22 Branch from palm - SoC only -# v0.8.3bSoC 05/Aug/22 Improved commenting, removed legacy code -# v0.8.4bSoC 31/Dec/22 Re-merge with Palm 0.8.4b, add example for second charge period -# v0.8.4cSoC 01/Jan/23 General tidy up -# v0.8.4dSoC 09/Jan/23 Updated GivEnergyObj to download & validate inverter commands -# v0.8.5SoC 04/May/23 Fixed midnight rollover issue in SoC calculation timing -# v0.9.0 01/Jun/23 30-minute SoC time-slices, auto-correct GMT/BST in Solcast data -# v0.9.1 03/Jun/23 Added logging functionality -# v0.9.2SoC 09/Jun/23 Merge with palm.py, including fallback inverter writes via API -# v0.9.3 18/Jun/23 Fixed significant bug in SoC calculation introduced in v0.9.2 +# ... # v0.10.0 21/Jun/23 Added multi-day averaging for usage calcs # v1.0.0 28/Jul/23 Align with palm v1.0.0: 48-hour forecast, minor bugfixes +# v1.1.0 06/Aug/23 Split out generic functions as palm_utils.py -PALM_VERSION = "v1.0.0SoC" +PALM_VERSION = "v1.1.0SoC" # -*- coding: utf-8 -*- +# pylint: disable=logging-not-lazy + + +def GivTCP_write_soc(cmd: str): + """Write SoC target directly to GivEnergy inverter. Fallback to API write""" + + if cmd == "set_soc": # Sets target SoC to value + try: + result={} + logger.debug("Setting Charge Target to: "+ str(inverter.tgt_soc)+ "%") + payload={} + payload['chargeToPercent']= inverter.tgt_soc + result=GivQueue.q.enqueue(wr.setChargeTarget,payload) + logger.debug(result) + except: + inverter.set_mode("set_soc") + + elif cmd == "set_soc_winter": # Restore default overnight charge params + try: + result={} + logger.debug("Setting Charge Target to: 100%") + payload={} + payload['chargeToPercent']= 100 + result=GivQueue.q.enqueue(wr.setChargeTarget,payload) + logger.debug(result) + except: + inverter.set_mode("set_soc_winter") + + else: + logger.critical("direct_write: Command not recognised") -class GivEnergyObj: - """Class for GivEnergy inverter""" - - def __init__(self): - sys_item = {'time': '', - 'solar': {'power': 0, 'arrays': - [{'array': 1, 'voltage': 0, 'current': 0, 'power': 0}, - {'array': 2, 'voltage': 0, 'current': 0, 'power': 0}]}, - 'grid': {'voltage': 0, 'current': 0, 'power': 0, 'frequency': 0}, - 'battery': {'percent': 0, 'power': 0, 'temperature': 0}, - 'inverter': {'temperature': 0, 'power': 0, 'output_voltage': 0, \ - 'output_frequency': 0, 'eps_power': 0}, - 'consumption': 0} - self.sys_status: List[str] = [sys_item] * 5 - - meter_item = {'time': '', - 'today': {'solar': 0, 'grid': {'import': 0, 'export': 0}, - 'battery': {'charge': 0, 'discharge': 0}, 'consumption': 0}, - 'total': {'solar': 0, 'grid': {'import': 0, 'export': 0}, - 'battery': {'charge': 0, 'discharge': 0}, 'consumption': 0}} - self.meter_status: List[str] = [meter_item] * 5 - - self.read_time_mins: int = -100 - self.line_voltage: float = 0 - self.grid_power: int = 0 - self.grid_energy: int = 0 - self.pv_power: int = 0 - self.pv_energy: int = 0 - self.batt_power: int = 0 - self.consumption: int = 0 - self.soc: int = 0 - self.base_load = stgs.GE.base_load - self.tgt_soc = 100 - - #Grab most recent data from invertor and store useful attributes - if exists(GivLUT.regcache): # if there is a cache then grab it - with open(GivLUT.regcache, 'rb') as inp: - regCacheStack = pickle.load(inp) - multi_output_old = regCacheStack[4] - self.invmaxrate=float(multi_output_old['Invertor_Details']['Invertor_Max_Bat_Rate']) / 1000 - self.batcap=float(multi_output_old['Invertor_Details']['Battery_Capacity_kWh']) - - - # v0.9.2: Removed routine to download valid inverter commands from GE API - not used - # in this version and superseded in palm.py to avoid errors if network is unreachable - # on initialisation - - def get_latest_data(self): - """Download latest data from GivEnergy.""" - - utc_timenow_mins = t_to_mins(time.strftime("%H:%M:%S", time.gmtime())) - if (utc_timenow_mins > self.read_time_mins + 5 or - utc_timenow_mins < self.read_time_mins): # Update every 5 minutes plus day rollover - - url = stgs.GE.url + "system-data/latest" - key = stgs.GE.key - headers = { - 'Authorization': 'Bearer ' + key, - 'Content-Type': 'application/json', - 'Accept': 'application/json' - } - - try: - resp = requests.request('GET', url, headers=headers, timeout=10) - except requests.exceptions.RequestException as error: - logger.error(error) - return - - if len(resp.content) > 100: - for index in range(4, -1, -1): # Right shift old data - if index > 0: - self.sys_status[index] = self.sys_status[index - 1] - else: - try: - self.sys_status[index] = \ - json.loads(resp.content.decode('utf-8'))['data'] - except Exception: - logger.error("Error reading GivEnergy system status "+ T_NOW_VAR) - logger.error(resp.content) - self.sys_status[index] = self.sys_status[index + 1] - if LOOP_COUNTER_VAR == 0: # Pack array on startup - i = 1 - while i < 5: - self.sys_status[i] = self.sys_status[0] - i += 1 - self.read_time_mins = t_to_mins(self.sys_status[0]['time'][11:]) - self.line_voltage = float(self.sys_status[0]['grid']['voltage']) - self.grid_power = -1 * int(self.sys_status[0]['grid']['power']) # -ve = export - self.pv_power = int(self.sys_status[0]['solar']['power']) - self.batt_power = int(self.sys_status[0]['battery']['power']) # -ve = charging - self.consumption = int(self.sys_status[0]['consumption']) - self.soc = int(self.sys_status[0]['battery']['percent']) - - url = stgs.GE.url + "meter-data/latest" - try: - resp = requests.request('GET', url, headers=headers, timeout=10) - except requests.exceptions.RequestException as error: - logger.error(error) - return - - if len(resp.content) > 100: - for i in range(4, -1, -1): # Right shift old data - if i > 0: - self.meter_status[i] = self.meter_status[i - 1] - else: - try: - self.meter_status[i] = \ - json.loads(resp.content.decode('utf-8'))['data'] - except Exception: - logger.error("Error reading GivEnergy meter status "+ T_NOW_VAR) - logger.error(resp.content) - self.meter_status[i] = self.meter_status[i + 1] - if LOOP_COUNTER_VAR == 0: # Pack array on startup - i = 1 - while i < 5: - self.meter_status[i] = self.meter_status[0] - i += 1 - - self.pv_energy = int(self.meter_status[0]['today']['solar'] * 1000) - - # Daily grid energy must be >=0 for PVOutput.org (battery charge >= midnight value) - self.grid_energy = max(int(self.meter_status[0]['today']['consumption'] * 1000), 0) - - def get_load_hist(self): - """Download historical consumption data from GivEnergy and pack array for next SoC calc""" - - def get_load_hist_day(offset: int): - """Get load history for a single day""" - - load_array = [0] * 48 - day_delta = 0 if (T_NOW_MINS_VAR > 1260) else 1 # Use latest day if after 2100 hrs - day_delta += offset - day = datetime.strftime(datetime.now() - timedelta(day_delta), '%Y-%m-%d') - url = stgs.GE.url + "data-points/"+ day - key = stgs.GE.key - headers = { - 'Authorization': 'Bearer ' + key, - 'Content-Type': 'application/json', - 'Accept': 'application/json' - } - params = { - 'page': '1', - 'pageSize': '2000' - } - - try: - resp = requests.request('GET', url, headers=headers, params=params, timeout=10) - except requests.exceptions.RequestException as error: - logger.error(error) - return load_array - if resp.status_code != 200: - logger.error("Invalid response: "+ str(resp.status_code)) - return load_array - - if len(resp.content) > 100: - history = json.loads(resp.content.decode('utf-8')) - i = 6 - counter = 0 - current_energy = prev_energy = 0 - while i < 290: - try: - current_energy = float(history['data'][i]['today']['consumption']) - except Exception: - break - if counter == 0: - load_array[counter] = round(current_energy, 1) - else: - load_array[counter] = round(current_energy - prev_energy, 1) - counter += 1 - prev_energy = current_energy - i += 6 - return load_array - - load_hist_array = [0] * 48 - acc_load = [0] * 48 - total_weight: int = 0 - - i: int = 0 - while i < len(stgs.GE.load_hist_weight): - if stgs.GE.load_hist_weight[i] > 0: - logger.info("Processing load history for day -"+ str(i + 1)) - load_hist_array = get_load_hist_day(i) - j = 0 - while j < 48: - acc_load[j] += load_hist_array[j] * stgs.GE.load_hist_weight[i] - acc_load[j] = round(acc_load[j], 2) - j += 1 - total_weight += stgs.GE.load_hist_weight[i] - logger.debug(str(acc_load)+ " total weight: "+ str(total_weight)) - else: - logger.info("Skipping load history for day -"+ str(i + 1)+ " (weight = 0)") - i += 1 - - # Calculate averages and write results - i = 0 - while i < 48: - self.base_load[i] = round(acc_load[i]/total_weight, 1) - i += 1 - - logger.info("Load Calc Summary: "+ str(self.base_load)) - - def set_mode(self, cmd: str, *arg: str): - """Configures inverter operating mode""" - - def set_inverter_register(register: str, value: str): - """Exactly as it says""" - - # Validate command against list in settings - cmd_name = "" - #valid_cmd = False - #for line in self.cmd_list: - #if line['id'] == int(register): - #cmd_name = line['name'] - #valid_cmd = True - #break - - #if valid_cmd is False: - #logger.critical("write attempt to invalid inverter register: "+ str(register)) - #return - - url = stgs.GE.url + "settings/"+ register + "/write" - key = stgs.GE.key - headers = { - 'Authorization': 'Bearer ' + key, - 'Content-Type': 'application/json', - 'Accept': 'application/json' - } - payload = { - 'value': value - } - resp = "TEST" - if not TEST_MODE: - try: - resp = requests.request('POST', url, headers=headers, json=payload, timeout=10) - except requests.exceptions.RequestException as error: - logger.error(error) - return - if resp.status_code != 201: - logger.info("Invalid response: "+ str(resp.status_code)) - return - - logger.info("Setting Register "+ str(register)+ " ("+ str(cmd_name) + ") to "+ - str(value)+ " Response: "+ str(resp)) - - time.sleep(3) - - # Readback check - url = stgs.GE.url + "settings/"+ register + "/read" - key = stgs.GE.key - headers = { - 'Authorization': 'Bearer ' + key, - 'Content-Type': 'application/json', - 'Accept': 'application/json' - } - payload = {} - - try: - resp = requests.request('POST', url, headers=headers, json=payload, timeout=10) - except resp.exceptions.RequestException as error: - logger.error(error) - return - if resp.status_code != 201: - logger.error("Invalid response: "+ resp.status_code) - return - - returned_cmd = json.loads(resp.content.decode('utf-8'))['data']['value'] - if str(returned_cmd) == str(value): - logger.info("Successful register read: "+ str(register)+ " = "+ str(returned_cmd)) - else: - logger.error("Readback failed on GivEnergy API... Expected " + - str(value) + ", Read: "+ str(returned_cmd)) - - if cmd == "set_soc": # Sets target SoC to value - try: - result={} - logger.debug("Setting Charge Target to: "+ str(arg[0])+ "%") - payload={} - payload['chargeToPercent']= arg[0] - result=GivQueue.q.enqueue(wr.setChargeTarget,payload) - logger.debug(result) - except: - set_inverter_register("77", arg[0]) - set_inverter_register("64", stgs.GE.start_time) - set_inverter_register("65", stgs.GE.end_time) - - elif cmd == "set_soc_winter": # Restore default overnight charge params - try: - result={} - logger.debug("Setting Charge Target to: "+ str(100)+ "%") - payload={} - payload['chargeToPercent']= 100 - result=GivQueue.q.enqueue(wr.setChargeTarget,payload) - logger.debug(result) - except: - set_inverter_register("77", "100") - set_inverter_register("64", stgs.GE.start_time) - set_inverter_register("65", stgs.GE.end_time_winter) - - elif cmd == "charge_now": - set_inverter_register("77", "100") - set_inverter_register("64", "00:30") - set_inverter_register("65", "23:59") - - elif cmd == "pause": - try: - result={} - logger.debug("Setting Charge rate to: 0W") - payload={} - payload['chargeRate']= 0 - result=GivQueue.q.enqueue(wr.setChargeRate,payload) - logger.debug(result) - result={} - logger.debug("Setting Discharge rate to: 0W") - payload={} - payload['dischargeRate']= 0 - result=GivQueue.q.enqueue(wr.setDischargeRate,payload) - logger.debug(result) - except: - set_inverter_register("72", "0") - set_inverter_register("73", "0") - elif cmd == "resume": - try: - result={} - logger.debug("Setting Charge rate to: "+str(self.invmaxrate)+"W") - payload={} - payload['chargeRate']= self.invmaxrate - result=GivQueue.q.enqueue(wr.setChargeRate,payload) - logger.debug(result) - result={} - logger.debug("Setting Discharge rate to: "+str(self.invmaxrate)+"W") - payload={} - payload['dischargeRate']= self.invmaxrate - result=GivQueue.q.enqueue(wr.setDischargeRate,payload) - logger.debug(result) - except: - set_inverter_register("72", "3000") - set_inverter_register("73", "3000") - - else: - logger.error("unknown inverter command: "+ cmd) - - def compute_tgt_soc(self, gen_fcast, weight: int, commit: bool): - """Compute overnight SoC target""" - - # Winter months = 100% - if MNTH_VAR in stgs.GE.winter and commit: # No need for sums... - logger.info("winter month, SoC set to 100") - self.set_mode("set_soc_winter") - return - - # Quick check for valid generation data - if gen_fcast.pv_est50_day[0] == 0: - logger.error("Missing generation data, SoC set to 100") - self.set_mode("set_soc") - return - - # Solcast provides 3 estimates (P10, P50 and P90). Compute individual weighting - # factors for each of the 3 estimates from the weight input parameter, using a - # triangular approximation for simplicity - - weight = min(max(weight,10),90) # Range check - wgt_10 = max(0, 50 - weight) - if weight > 50: - wgt_50 = 90 - weight - else: - wgt_50 = weight - 10 - wgt_90 = max(0, weight - 50) - - logger.debug("") - logger.debug("{:<20} {:>10} {:>10} {:>10} {:>10} {:>10} {:>10}".format("SoC Calc;", - "Day", "Hour", "Charge", "Cons", "Gen", "SoC")) - - if stgs.GE.end_time != "": - end_charge_period = int(stgs.GE.end_time[0:2]) * 2 - else: - end_charge_period = 8 - - batt_max_charge: float = stgs.GE.batt_max_charge - batt_charge: float = [0] * 98 - reserve_energy = batt_max_charge * stgs.GE.batt_reserve / 100 - max_charge_pcnt = [0] * 2 - min_charge_pcnt = [0] * 2 - - # The clever bit: - # Start with battery at reserve %. For each 30-minute slot of the coming day, calculate - # the battery charge based on forecast generation and historical usage. Capture values - # for maximum charge and also the minimum charge value at any time before the maximum. - - day = 0 - while day < 2: # Repeat for tomorrow and next day - batt_charge[0] = max_charge = min_charge = reserve_energy - est_gen = 0 - i = 0 - while i < 48: - if i <= end_charge_period: # Battery is in AC Charge mode - total_load = 0 - batt_charge[i] = batt_charge[0] - else: - total_load = ge.base_load[i] - est_gen = (gen_fcast.pv_est10_30[day*48 + i] * wgt_10 + - gen_fcast.pv_est50_30[day*48 + i] * wgt_50 + - gen_fcast.pv_est90_30[day*48 + i] * wgt_90) / (wgt_10 + wgt_50 + wgt_90) - batt_charge[i] = (batt_charge[i - 1] + - max(-1 * stgs.GE.charge_rate, - min(stgs.GE.charge_rate, (est_gen - total_load)))) - - # Capture min charge on lowest point on down-slope before charge reaches 100% - # or max charge if on an up slope after overnight charge - if (batt_charge[i] <= batt_charge[i - 1] and - max_charge < batt_max_charge): - min_charge = min(min_charge, batt_charge[i]) - elif i > end_charge_period: # Charging after overnight boost - max_charge = max(max_charge, batt_charge[i]) - - logger.info("{:<20} {:>10} {:>10} {:>10} {:>10} {:>10} {:>10}".format("SoC Calc;", - day, t_to_hrs(i * 30), round(batt_charge[i], 2), round(total_load, 2), - round(est_gen, 2), int(100 * batt_charge[i] / batt_max_charge))) - i += 1 - - max_charge_pcnt[day] = int(100 * max_charge / batt_max_charge) - min_charge_pcnt[day] = int(100 * min_charge / batt_max_charge) - - day += 1 - - # low_soc is the minimum SoC target. Provide more buffer capacity in shoulder months - # when load is likely to be more variable, e.g. heating - if MNTH_VAR in stgs.GE.shoulder: - low_soc = stgs.GE.max_soc_target - else: - low_soc = stgs.GE.min_soc_target - - # So we now have the four values of max & min charge for tomorrow & overmorrow - # Check if overmorrow is better than tomorrow and there is opportunity to reduce target - # to avoid residual charge at the end of the day in anticipation of a sunny day - if max_charge_pcnt[1] > 100 - low_soc > max_charge_pcnt[0]: - logger.info("Overmorrow correction applied") - max_charge_pc = max_charge_pcnt[0] + (max_charge_pcnt[1] - 100) / 2 - else: - logger.info("Overmorrow correction not needed/applied") - max_charge_pc = max_charge_pcnt[0] - min_charge_pc = min_charge_pcnt[0] - - print("Min & max", min_charge_pc, max_charge_pc) - # The really clever bit: reduce the target SoC to the greater of: - # The surplus above 100% for max_charge_pcnt - # The value needed to achieve the stated spare capacity at minimum charge point - # The preset minimum value - tgt_soc = max(100 - max_charge_pc, (low_soc - min_charge_pc), low_soc) - # Range check the resulting value - tgt_soc = int(min(tgt_soc, 100)) # Limit range to 100% - - # Produce SoC plots (y1 = baseline, y2 = adjusted) - - logger.info("{:<25} {:>10} {:>10} {:>10} {:>10} {:>10}".format("SoC Calc Summary;", - "Max Charge", "Min Charge", "Max %", "Min %", "Target SoC")) - logger.info("{:<25} {:>10} {:>10} {:>10} {:>10} {:>10}".format("SoC Calc Summary;", - round(max_charge, 2), round(min_charge, 2), - max_charge_pc, min_charge_pc, tgt_soc)) - logger.info("{:<25} {:>10} {:>10} {:>10} {:>10} {:>10}".format("SoC (Adjusted);", - round(max_charge, 2), round(min_charge, 2), - max_charge_pc + tgt_soc, min_charge_pc + tgt_soc, "\n")) - - if commit: - logger.critical("Sending calculated SoC to inverter: "+ str(tgt_soc)) - self.set_mode("set_soc", str(tgt_soc)) - self.tgt_soc = tgt_soc - -# End of GivEnergyObj() class definition - -class SolcastObj: - """Stores and manipulates daily Solcast forecast.""" - - def __init__(self): - # Skeleton solcast summary array - self.pv_est10_day: [int] = [0] * 7 - self.pv_est50_day: [int] = [0] * 7 - self.pv_est90_day: [int] = [0] * 7 - - self.pv_est10_30: [int] = [0] * 96 - self.pv_est50_30: [int] = [0] * 96 - self.pv_est90_30: [int] = [0] * 96 - - def update(self): - """Updates forecast generation from Solcast server.""" - - def get_solcast(url) -> Tuple[bool, str]: - """Download latest Solcast forecast.""" - - solcast_url = url + stgs.Solcast.cmd + "&api_key=" + stgs.Solcast.key - try: - resp = requests.get(solcast_url, timeout=5) - resp.raise_for_status() - except requests.exceptions.RequestException as error: - logger.error(error) - return False, "" - if resp.status_code != 200: - logger.error("Invalid response: "+ str(resp.status_code)) - return False, "" - - if len(resp.content) < 50: - logger.warning("Warning: Solcast data missing/short") - logger.warning(resp.content) - return False, "" - - solcast_data = json.loads(resp.content.decode('utf-8')) - logger.debug(str(solcast_data)) - - return True, solcast_data - # End of get_solcast() - - # Download latest data for each array, abort if unsuccessful - result, solcast_data_1 = get_solcast(stgs.Solcast.url_se) - if not result: - logger.warning("Error; Problem reading Solcast data, using previous values (if any)") - return - - if stgs.Solcast.url_sw != "": # Two arrays are specified - logger.info("url_sw = '"+str(stgs.Solcast.url_sw)+"'") - result, solcast_data_2 = get_solcast(stgs.Solcast.url_sw) - if not result: - logger.warning("Error; Problem reading Solcast data, using previous values (if any)") - return - else: - logger.info("No second array") - - logger.info("Successful Solcast download.") - - # Combine forecast for PV arrays & align data with day boundaries - pv_est10 = [0] * 10080 - pv_est50 = [0] * 10080 - pv_est90 = [0] * 10080 - - if stgs.Solcast.url_sw != "": # Two arrays are specified - forecast_lines = min(len(solcast_data_1['forecasts']), len(solcast_data_2['forecasts'])) - 1 - else: - forecast_lines = len(solcast_data_1['forecasts']) - 1 - interval = int(solcast_data_1['forecasts'][0]['period'][2:4]) - solcast_offset = (60 * int(solcast_data_1['forecasts'][0]['period_end'][11:13]) + - int(solcast_data_1['forecasts'][0]['period_end'][14:16]) - interval - 60) - - # Check for BST and convert to local time - if time.strftime("%z", time.localtime()) == "+0100": - logger.info("Applying BST offset to Solcast data") - solcast_offset += 60 - - i = solcast_offset - cntr = 0 - while i < solcast_offset + forecast_lines * interval: - try: - if stgs.Solcast.url_sw != "": # Two arrays are specified - pv_est10[i] = (int(solcast_data_1['forecasts'][cntr]['pv_estimate10'] * 1000) + - int(solcast_data_2['forecasts'][cntr]['pv_estimate10'] * 1000)) - pv_est50[i] = (int(solcast_data_1['forecasts'][cntr]['pv_estimate'] * 1000) + - int(solcast_data_2['forecasts'][cntr]['pv_estimate'] * 1000)) - pv_est90[i] = (int(solcast_data_1['forecasts'][cntr]['pv_estimate90'] * 1000) + - int(solcast_data_2['forecasts'][cntr]['pv_estimate90'] * 1000)) - else: - pv_est10[i] = int(solcast_data_1['forecasts'][cntr]['pv_estimate10'] * 1000) - pv_est50[i] = int(solcast_data_1['forecasts'][cntr]['pv_estimate'] * 1000) - pv_est90[i] = int(solcast_data_1['forecasts'][cntr]['pv_estimate90'] * 1000) - except Exception: - logger.error("Error: Unexpected end of Solcast data. i="+ str(i)+ "cntr="+ str(cntr)) - break - - if i > 1 and i % interval == 0: - cntr += 1 - i += 1 - - if solcast_offset > 720: # Forget about current day - offset = 1440 - 90 - else: - offset = 0 - - i = 0 - while i < 7: # Summarise daily forecasts - start = i * 1440 + offset + 1 - end = start + 1439 - self.pv_est10_day[i] = round(sum(pv_est10[start:end]) / 60000, 3) - self.pv_est50_day[i] = round(sum(pv_est50[start:end]) / 60000, 3) - self.pv_est90_day[i] = round(sum(pv_est90[start:end]) / 60000, 3) - i += 1 - - i = 0 - while i < 96: # Calculate half-hourly generation - start = i * 30 + offset + 1 - end = start + 29 - self.pv_est10_30[i] = round(sum(pv_est10[start:end])/60000, 3) - self.pv_est50_30[i] = round(sum(pv_est50[start:end])/60000, 3) - self.pv_est90_30[i] = round(sum(pv_est90[start:end])/60000, 3) - i += 1 - - timestamp = time.strftime("%d-%m-%Y %H:%M:%S", time.localtime()) - logger.debug("PV Estimate 10% (hrly, 7 days) / kWh; "+ timestamp+ "; "+ - str(self.pv_est10_30[0:47])+ str(self.pv_est10_day[0:6])) - logger.debug("PV Estimate 50% (hrly, 7 days) / kWh; "+ timestamp+ "; "+ - str(self.pv_est50_30[0:47])+ str(self.pv_est50_day[0:6])) - logger.debug("PV Estimate 90% (hrly, 7 days) / kWh; "+ timestamp+ "; "+ - str(self.pv_est90_30[0:47])+ str(self.pv_est90_day[0:6])) - -# End of SolcastObj() class definition - -def t_to_mins(time_in_hrs: str) -> int: - """Convert times from HH:MM format to mins after midnight.""" - - try: - time_in_mins = 60 * int(time_in_hrs[0:2]) + int(time_in_hrs[3:5]) - return time_in_mins - except Exception: - return 0 - -# End of t_to_mins() - -def t_to_hrs(time_in: int) -> str: - """Convert times from mins after midnight format to HH:MM.""" - - try: - hours = int(time_in // 60) - mins = int(time_in - hours * 60) - time_in_hrs = '{:02d}{}{:02d}'.format(hours, ":", mins) - return time_in_hrs - except Exception: - return "00:00" - -# End of t_to_hrs() if __name__ == '__main__': - LOOP_COUNTER_VAR = 0 - TEST_MODE: bool = False - DEBUG_MODE: bool = False - LONG_T_NOW_VAR: str = time.strftime("%d-%m-%Y %H:%M:%S %z", time.localtime()) - MNTH_VAR: str = LONG_T_NOW_VAR[3:5] - T_NOW_VAR: str = LONG_T_NOW_VAR[11:] - T_NOW_MINS_VAR: int = t_to_mins(T_NOW_VAR) - - logger.info("PALM... PV Automated Load Manager Version:"+ str(PALM_VERSION)) - - # GivEnergy power object initialisation - ge: GivEnergyObj = GivEnergyObj() - -# if exists(ge.batcap): -# logger.info("Battery Capacity: "+ str(ge.batcap)) - - # Solcast PV prediction object initialisation - solcast: SolcastObj = SolcastObj() - solcast.update() - - try: - ge.get_load_hist() - logger.debug("10% forecast...") - ge.compute_tgt_soc(solcast, 10, False) - logger.debug("50% forecast...") - ge.compute_tgt_soc(solcast, 50, False) - logger.debug("90% forecast...") - ge.compute_tgt_soc(solcast, 90, False) - except Exception: - logger.critical("Unable to set SoC") - - # Write final SoC target to GivEnergy register - # Change weighting in command according to desired risk/reward profile - logger.info("Forecast weighting: "+ str(stgs.Solcast.weight)) - ge.compute_tgt_soc(solcast, stgs.Solcast.weight, True) + if DEBUG_SW: + logging.basicConfig(level=logging.INFO) + logger = logging.getLogger("PALM") + else: + logger = GivLUT.logger + + # Set time variables + stgs.pg.long_t_now: str = time.strftime("%d-%m-%Y %H:%M:%S %z", time.localtime()) + stgs.pg.month: str = stgs.pg.long_t_now[3:5] + stgs.pg.t_now: str = stgs.pg.long_t_now[11:] + stgs.pg.t_now_mins: int = t_to_mins(stgs.pg.t_now) + + logger.info("PALM... PV Automated Load Manager: "+ str(PALM_VERSION)) + logger.info("Timestamp: "+ str(stgs.pg.long_t_now)) + + # Initialise inverter object (GivEnergy) + inverter: GivEnergyObj = GivEnergyObj() + + # Initialise PV prediction object (Solcast) + pv_forecast: SolcastObj = SolcastObj() + PV_WEIGHT = stgs.Solcast.weight + + # Download inverter load history + inverter.get_load_hist() + + # Download and parse PV forecast + pv_forecast.update() + + # Compute target SoC and write directly to register in GivEnergy inverter + logger.info("Forecast weighting: "+ str(PV_WEIGHT)) + GivTCP_write_soc(inverter.compute_tgt_soc(pv_forecast, PV_WEIGHT, True)) + + # Send plot data to logfile in CSV format + logger.info("SoC Chart Data - Start") + i = 0 + while i < 5: + logger.info(inverter.plot[i]) + i += 1 + logger.info("SoC Chart Data - End") # End of main From b79376e97512430e6c13c14b9d0586368ecd4b8b Mon Sep 17 00:00:00 2001 From: salewis38 <46199139+salewis38@users.noreply.github.com> Date: Sat, 19 Aug 2023 09:01:03 +0100 Subject: [PATCH 4/9] Minor edits to improve linting score --- GivTCP/GivLUT.py | 28 ++++++++++++++++------------ 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/GivTCP/GivLUT.py b/GivTCP/GivLUT.py index 3f0395d..ddf72cd 100644 --- a/GivTCP/GivLUT.py +++ b/GivTCP/GivLUT.py @@ -1,21 +1,24 @@ +"""GivLUT: Various objects to interface to GivEnergy inverters """ + class GivClient: + """Definition of GivEnergy client """ def getData(fullrefresh: bool): from givenergy_modbus.client import GivEnergyClient from settings import GiV_Settings - from givenergy_modbus.model.plant import Plant + from givenergy_modbus.model.plant import Plant client= GivEnergyClient(host=GiV_Settings.invertorIP) numbat=GiV_Settings.numBatteries plant=Plant(number_batteries=numbat) client.refresh_plant(plant,GiV_Settings.isAIO,GiV_Settings.isAC,fullrefresh) - return (plant) - + return plant + class GivQueue: from redis import Redis from rq import Queue from settings import GiV_Settings redis_connection = Redis(host='127.0.0.1', port=6379, db=0) q = Queue("GivTCP_"+str(GiV_Settings.givtcp_instance),connection=redis_connection) - + class GEType: def __init__(self,dT,sC,cF,mn,mx,aZ,sM,oI): self.devType = dT @@ -40,7 +43,8 @@ class GivLUT: import logging, os, zoneinfo from settings import GiV_Settings from logging.handlers import TimedRotatingFileHandler - logging.basicConfig(format='%(asctime)s - Inv'+ str(GiV_Settings.givtcp_instance)+' - %(module)-11s - [%(levelname)-8s] - %(message)s') + logging.basicConfig(format='%(asctime)s - Inv'+ str(GiV_Settings.givtcp_instance)+ \ + ' - %(module)-11s - [%(levelname)-8s] - %(message)s') formatter = logging.Formatter( '%(asctime)s - %(module)s - [%(levelname)s] - %(message)s') fh = TimedRotatingFileHandler(GiV_Settings.Debug_File_Location, when='midnight', backupCount=7) @@ -77,7 +81,7 @@ class GivLUT: if hasattr(GiV_Settings,'timezone'): # If in Addon, use the HA Supervisor timezone - timezone=zoneinfo.ZoneInfo(key=GiV_Settings.timezone) + timezone=zoneinfo.ZoneInfo(key=GiV_Settings.timezone) elif "TZ" in os.environ: # Otherwise use the ENV (for Docker) timezone=zoneinfo.ZoneInfo(key=os.getenv("TZ")) else: @@ -182,7 +186,7 @@ class GivLUT: "Discharge_end_time_slot_10":GEType("select","","setDischargeEnd10","","",False,False,False), "Battery_pause_start_time_slot":GEType("select","","setPauseStart","","",False,False,False), "Battery_pause_end_time_slot":GEType("select","","setPauseEnd","","",False,False,False), - + "Charge_start_time_slot_1":GEType("select","","setChargeStart1","","",False,False,False), "Charge_end_time_slot_1":GEType("select","","setChargeEnd1","","",False,False,False), "Charge_start_time_slot_2":GEType("select","","setChargeStart2","","",False,False,False), @@ -203,7 +207,7 @@ class GivLUT: "Charge_end_time_slot_9":GEType("select","","setChargeEnd9","","",False,False,False), "Charge_start_time_slot_10":GEType("select","","setChargeStart10","","",False,False,False), "Charge_end_time_slot_10":GEType("select","","setChargeEnd10","","",False,False,False), - + "Battery_Serial_Number":GEType("sensor","string","","","",False,True,False), "Battery_SOC":GEType("sensor","battery","",0,100,False,False,False), "Battery_Capacity":GEType("sensor","","",0,250,False,True,False), @@ -322,7 +326,7 @@ class GivLUT: "22:00:00","22:01:00","22:02:00","22:03:00","22:04:00","22:05:00","22:06:00","22:07:00","22:08:00","22:09:00","22:10:00","22:11:00","22:12:00","22:13:00","22:14:00","22:15:00","22:16:00","22:17:00","22:18:00","22:19:00","22:20:00","22:21:00","22:22:00","22:23:00","22:24:00","22:25:00","22:26:00","22:27:00","22:28:00","22:29:00","22:30:00","22:31:00","22:32:00","22:33:00","22:34:00","22:35:00","22:36:00","22:37:00","22:38:00","22:39:00","22:40:00","22:41:00","22:42:00","22:43:00","22:44:00","22:45:00","22:46:00","22:47:00","22:48:00","22:49:00","22:50:00","22:51:00","22:52:00","22:53:00","22:54:00","22:55:00","22:56:00","22:57:00","22:58:00","22:59:00", "23:00:00","23:01:00","23:02:00","23:03:00","23:04:00","23:05:00","23:06:00","23:07:00","23:08:00","23:09:00","23:10:00","23:11:00","23:12:00","23:13:00","23:14:00","23:15:00","23:16:00","23:17:00","23:18:00","23:19:00","23:20:00","23:21:00","23:22:00","23:23:00","23:24:00","23:25:00","23:26:00","23:27:00","23:28:00","23:29:00","23:30:00","23:31:00","23:32:00","23:33:00","23:34:00","23:35:00","23:36:00","23:37:00","23:38:00","23:39:00","23:40:00","23:41:00","23:42:00","23:43:00","23:44:00","23:45:00","23:46:00","23:47:00","23:48:00","23:49:00","23:50:00","23:51:00","23:52:00","23:53:00","23:54:00","23:55:00","23:56:00","23:57:00","23:58:00","23:59:00" ] - + delay_times=["Normal","Running","Cancel","2","15","30","45","60","90","120","150","180"] modes=["Eco","Eco (Paused)","Timed Demand","Timed Export","Unknown"] rates=["Day","Night"] @@ -332,8 +336,8 @@ class GivLUT: def getTime(timestamp): timeslot=timestamp.strftime("%H:%M") - return (timeslot) - + return timeslot + ''' Firmware Versions for each Model @@ -342,4 +346,4 @@ def getTime(timestamp): Gen 2 909+ New. 99x Beta Gen3 303+ New 39x Beta AIO 6xx New 69x Beta -''' \ No newline at end of file +''' From d2720889b723503f1f9b9c652ef428ecda271e1c Mon Sep 17 00:00:00 2001 From: salewis38 <46199139+salewis38@users.noreply.github.com> Date: Sat, 19 Aug 2023 09:10:51 +0100 Subject: [PATCH 5/9] Minor edits to improve linting score --- GivTCP/HA_Discovery.py | 27 ++++++++++++++------------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/GivTCP/HA_Discovery.py b/GivTCP/HA_Discovery.py index b5fb740..d94e3e1 100644 --- a/GivTCP/HA_Discovery.py +++ b/GivTCP/HA_Discovery.py @@ -1,6 +1,9 @@ +"""HA_Discovery: """ # version 2022.01.21 -import sys, time, json -import paho.mqtt.client as mqtt +import sys +import time +import json +import paho.mqtt.client as mqtt from settings import GiV_Settings from givenergy_modbus.model.inverter import Model from mqtt import GivMQTT @@ -40,7 +43,7 @@ def on_connect(client, userdata, flags, rc): #client.subscribe(topic) else: logger.error("Bad connection Returned code= "+str(rc)) - + def publish_discovery(array,SN): #Recieve multiple payloads with Topics and publish in a single MQTT connection mqtt.Client.connected_flag=False #create flag in class client=mqtt.Client("GivEnergy_GivTCP_"+str(GiV_Settings.givtcp_instance)) @@ -82,16 +85,14 @@ def publish_discovery(array,SN): #Recieve multiple payloads with Topics and pu # client.publish("homeassistant2/binary_sensor/GivEnergy/"+str(topic).split("/")[-1]+"/config",HAMQTT.create_binary_sensor_payload(topic,SN),retain=True) elif GivLUT.entity_type[str(topic).split("/")[-1]].devType=="select": client.publish("homeassistant/select/GivEnergy/"+SN+"_"+str(topic).split("/")[-1]+"/config",HAMQTT.create_device_payload(topic,SN),retain=True) - + client.loop_stop() #Stop loop client.disconnect() - + except: e = sys.exc_info() logger.error("Error connecting to MQTT Broker: " + str(e)) - - return - + def create_device_payload(topic,SN): tempObj={} @@ -100,7 +101,7 @@ def create_device_payload(topic,SN): tempObj["pl_avail"]= "online" tempObj["pl_not_avail"]= "offline" tempObj['device']={} - + GiVTCP_Device=str(topic).split("/")[2] if "Battery_Details" in topic: tempObj["name"]=GiV_Settings.ha_device_prefix+" "+str(topic).split("/")[3].replace("_"," ")+" "+str(topic).split("/")[-1].replace("_"," ") #Just final bit past the last "/" @@ -161,12 +162,12 @@ def create_device_payload(topic,SN): tempObj['device_class']="Battery" tempObj['state_class']="measurement" if GivLUT.entity_type[str(topic).split("/")[-1]].sensorClass=="timestamp": - del(tempObj['unit_of_meas']) + del tempObj['unit_of_meas'] tempObj['device_class']="timestamp" if GivLUT.entity_type[str(topic).split("/")[-1]].sensorClass=="datetime": - del(tempObj['unit_of_meas']) + del tempObj['unit_of_meas'] if GivLUT.entity_type[str(topic).split("/")[-1]].sensorClass=="string": - del(tempObj['unit_of_meas']) + del tempObj['unit_of_meas'] elif GivLUT.entity_type[str(topic).split("/")[-1]].devType=="switch": tempObj['payload_on']="enable" tempObj['payload_off']="disable" @@ -207,4 +208,4 @@ def create_device_payload(topic,SN): tempObj['payload_press']="restart" ## Convert this object to json string jsonOut=json.dumps(tempObj) - return(jsonOut) + return jsonOut From 5176f38ce2e117cbd948d8d485c67befeedc16d6 Mon Sep 17 00:00:00 2001 From: salewis38 <46199139+salewis38@users.noreply.github.com> Date: Sat, 19 Aug 2023 09:18:30 +0100 Subject: [PATCH 6/9] Minor edits to improve linting score --- GivTCP/REST.py | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/GivTCP/REST.py b/GivTCP/REST.py index a053090..9e1991f 100644 --- a/GivTCP/REST.py +++ b/GivTCP/REST.py @@ -1,12 +1,12 @@ # -*- coding: utf-8 -*- # version 2021.12.22 -from flask import Flask, json, request +from os.path import exists +from flask import Flask, request from flask_cors import CORS import read as rd #grab passthrough functions from main read file import write as wr #grab passthrough functions from main write file import config_dash as cfdash from GivLUT import GivQueue, GivLUT -from os.path import exists logger = GivLUT.logger @@ -19,7 +19,7 @@ def get_config_page(): if request.method=="GET": return cfdash.get_config() - elif request.method=="POST": + if request.method=="POST": return cfdash.set_config(request.form) #Read from Invertor put in cache and publish @@ -41,7 +41,7 @@ def rdData(): def gtCache(): return rd.getCache() -#Read from Invertor put in cache +# Read from Invertor put in cache @giv_api.route('/getData', methods=['GET']) def gtData(): return GivQueue.q.enqueue(rd.getData,True) @@ -51,7 +51,7 @@ def gtData(): def enChargeTrgt(): payload = request.get_json(silent=True, force=True) return wr.enableChargeTarget(payload) - + @giv_api.route('/enableChargeSchedule', methods=['POST']) def enableChrgSchedule(): payload = request.get_json(silent=True, force=True) @@ -148,8 +148,7 @@ def frceChrg(): return wr.cancelJob(jobid) else: logger.error("Force Charge is not currently running") - else: - return wr.forceCharge(payload) + return wr.forceCharge(payload) @giv_api.route('/forceExport', methods=['POST']) def frceExprt(): @@ -161,8 +160,7 @@ def frceExprt(): return wr.cancelJob(jobid) else: logger.error("Force Charge is not currently running") - else: - return wr.forceExport(payload) + return wr.forceExport(payload) @giv_api.route('/setBatteryMode', methods=['POST']) def setBattMode(): @@ -180,4 +178,4 @@ def swRates(): return wr.switchRate(payload) if __name__ == "__main__": - giv_api.run() \ No newline at end of file + giv_api.run() From 1c216b83e272ab6eda33aea58ce1b9cddbe6354f Mon Sep 17 00:00:00 2001 From: salewis38 <46199139+salewis38@users.noreply.github.com> Date: Thu, 21 Sep 2023 21:55:36 +0100 Subject: [PATCH 7/9] Minor updates Removed randomised charge start time (any jitter can be user-configured in palm_settings.py) Added extra inverter commands for alignment with standalone PALM (not used here) Tidied overmorrow logic --- GivTCP/palm_utils.py | 34 +++++++++++++++++++++++----------- 1 file changed, 23 insertions(+), 11 deletions(-) diff --git a/GivTCP/palm_utils.py b/GivTCP/palm_utils.py index 6424712..fbca95c 100644 --- a/GivTCP/palm_utils.py +++ b/GivTCP/palm_utils.py @@ -7,7 +7,6 @@ from typing import Tuple, List import logging ## import matplotlib.pyplot as plt -import random import requests import palm_settings as stgs @@ -49,7 +48,7 @@ # ... # v0.10.0 21/Jun/23 Added multi-day averaging for usage calcs # v1.0.0 15/Jul/23 Random start time, Solcast data correction, IO compatibility, 48-hour fcast -# v1.1.0 06/Aug/23 Split out generic functions as palm_utils.py (this file) +# v1.1.0 06/Aug/23 Split out generic functions as palm_utils.py (this file), remove random start time (add comment in settings instead) PALM_VERSION = "v1.1.0" # -*- coding: utf-8 -*- @@ -324,10 +323,10 @@ def set_inverter_register(register: str, value: str): logger.error("Readback failed on GivEnergy API... Expected " + str(value) + ", Read: "+ str(returned_cmd)) - if cmd == "set_soc": # Sets target SoC to value, randomises start time to be grid friendly + if cmd == "set_soc": # Sets target SoC to value set_inverter_register("77", str(self.tgt_soc)) if stgs.GE.start_time != "": - start_time = t_to_hrs(t_to_mins(stgs.GE.start_time) + random.randint(1,14)) + start_time = t_to_hrs(t_to_mins(stgs.GE.start_time)) set_inverter_register("64", start_time) if stgs.GE.end_time != "": set_inverter_register("65", stgs.GE.end_time) @@ -335,7 +334,7 @@ def set_inverter_register(register: str, value: str): elif cmd == "set_soc_winter": # Restore default overnight charge params set_inverter_register("77", "100") if stgs.GE.start_time != "": - start_time = t_to_hrs(t_to_mins(stgs.GE.start_time) + random.randint(1,14)) + start_time = t_to_hrs(t_to_mins(stgs.GE.start_time)) set_inverter_register("64", stgs.GE.start_time) if stgs.GE.end_time_winter != "": set_inverter_register("65", stgs.GE.end_time_winter) @@ -345,10 +344,21 @@ def set_inverter_register(register: str, value: str): set_inverter_register("64", "00:01") set_inverter_register("65", "23:59") + elif cmd == "charge_now_soc": + set_inverter_register("77", str(self.tgt_soc)) + set_inverter_register("64", "00:01") + set_inverter_register("65", "23:59") + elif cmd == "pause": set_inverter_register("72", "0") set_inverter_register("73", "0") + elif cmd == "pause_charge": + set_inverter_register("72", "0") + + elif cmd == "pause_discharge": + set_inverter_register("73", "0") + elif cmd == "resume": set_inverter_register("72", "3000") set_inverter_register("73", "3000") @@ -463,16 +473,18 @@ def compute_tgt_soc(self, gen_fcast, weight: int, commit: bool) -> str: else: low_soc = stgs.GE.min_soc_target - # So we now have the four values of max & min charge for tomorrow & overmorrow + max_charge_pc = max_charge_pcnt[0] + min_charge_pc = min_charge_pcnt[0] + + # We now have the four values of max & min charge for tomorrow & overmorrow # Check if overmorrow is better than tomorrow and there is opportunity to reduce target - # to avoid residual charge at the end of the day in anticipation of a sunny day - if max_charge_pcnt[1] > 100 - low_soc > max_charge_pcnt[0]: + # to avoid residual charge at the end of the day in anticipation of a sunny day. + # Reduce the target by implying that there will be more than forecast generation + if max_charge_pcnt[1] > 100 and max_charge_pcnt[0] < 100: logger.info("Overmorrow correction enabled") - max_charge_pc = max_charge_pcnt[0] + (max_charge_pcnt[1] - 100) / 2 + max_charge_pc += int((max_charge_pcnt[1] - 100) / 2) else: logger.info("Overmorrow correction not needed/enabled") - max_charge_pc = max_charge_pcnt[0] - min_charge_pc = min_charge_pcnt[0] # The really clever bit: reduce the target SoC to the greater of: # The surplus above 100% for max_charge_pcnt From 6032daec266318b0a5c7bd190856b9ae86107844 Mon Sep 17 00:00:00 2001 From: salewis38 <46199139+salewis38@users.noreply.github.com> Date: Thu, 21 Sep 2023 22:00:26 +0100 Subject: [PATCH 8/9] Added missing timeout to request Best practice to declare a timeout on all requests. Now added. --- GivTCP/palm_utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/GivTCP/palm_utils.py b/GivTCP/palm_utils.py index fbca95c..b241654 100644 --- a/GivTCP/palm_utils.py +++ b/GivTCP/palm_utils.py @@ -111,7 +111,7 @@ def get_latest_data(self): } try: - resp = requests.request('GET', url, headers=headers) + resp = requests.request('GET', url, headers=headers, timeout=10) except requests.exceptions.RequestException as error: logger.error(error) return From c1af0bcd08e75d3885dc1d6ed64e8427aae9e5f7 Mon Sep 17 00:00:00 2001 From: salewis38 <46199139+salewis38@users.noreply.github.com> Date: Mon, 25 Sep 2023 19:30:30 +0100 Subject: [PATCH 9/9] Added type declaration to variable tgt_soc --- GivTCP/palm_utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/GivTCP/palm_utils.py b/GivTCP/palm_utils.py index b241654..108de07 100644 --- a/GivTCP/palm_utils.py +++ b/GivTCP/palm_utils.py @@ -87,7 +87,7 @@ def __init__(self): self.consumption: int = 0 self.soc: int = 0 self.base_load = stgs.GE.base_load - self.tgt_soc = 100 + self.tgt_soc: int = 100 self.cmd_list = stgs.GE_Command_list['data'] self.plot = [""] * 5