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