diff --git a/README.md b/README.md index 1c1756d..eb8990b 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# PV Opt: Home Assistant Solar/Battery Optimiser v3.10.0 +# PV Opt: Home Assistant Solar/Battery Optimiser v3.11.0 Solar / Battery Charging Optimisation for Home Assistant. This appDaemon application attempts to optimise charging and discharging of a home solar/battery system to minimise cost electricity cost on a daily basis using freely available solar forecast data from SolCast. This is particularly beneficial for Octopus Agile but is also benefeficial for other time-of-use tariffs such as Octopus Flux or simple Economy 7. diff --git a/apps/pv_opt/config/config.yaml b/apps/pv_opt/config/config.yaml index 8a1db8f..4c8fc0e 100644 --- a/apps/pv_opt/config/config.yaml +++ b/apps/pv_opt/config/config.yaml @@ -3,6 +3,10 @@ pvpy: module: pvpy global: true +solis: + module: solis + global: true + inverters: module: inverters global: true diff --git a/apps/pv_opt/pv_opt.py b/apps/pv_opt/pv_opt.py index 89f0834..bbf9703 100644 --- a/apps/pv_opt/pv_opt.py +++ b/apps/pv_opt/pv_opt.py @@ -18,7 +18,7 @@ USE_TARIFF = True -VERSION = "3.10.0" +VERSION = "3.11.0" DEBUG = False DATE_TIME_FORMAT_LONG = "%Y-%m-%d %H:%M:%S%z" @@ -94,6 +94,16 @@ }, "domain": "number", }, + "solcast_confidence_level": { + "default": 50, + "attributes": { + "min": 10, + "max": 90, + "step": 10, + "mode": "slider", + }, + "domain": "number", + }, "slot_threshold_p": { "default": 1.0, "attributes": { @@ -207,7 +217,7 @@ }, "solar_forecast": { "default": "Solcast", - "attributes": {"options": ["Solcast", "Solcast_p10", "Solcast_p90"]}, + "attributes": {"options": ["Solcast", "Solcast_p10", "Solcast_p90", "Weighted"]}, "domain": "select", }, "id_solcast_today": {"default": "sensor.solcast_pv_forecast_forecast_today"}, @@ -1294,6 +1304,7 @@ def optimise(self): # Load Solcast solcast = self.load_solcast() + if solcast is None: self.log("") self.log("Unable to optimise without Solcast data.", level="ERROR") @@ -1356,7 +1367,8 @@ def optimise(self): self.base = self.pv_system.flows( self.initial_soc, self.static, - solar=self.get_config("solar_forecast"), + # solar="self.get_config("solar_forecast")", + solar="weighted", ) if len(self.base) == 0: @@ -1378,7 +1390,8 @@ def optimise(self): self.initial_soc, self.static, self.contract, - solar=self.get_config("solar_forecast"), + # solar="self.get_config("solar_forecast")", + solar="weighted", discharge=self.get_config("forced_discharge"), max_iters=MAX_ITERS, ) @@ -1886,6 +1899,17 @@ def load_solcast(self): df.index = pd.to_datetime(df.index, utc=True) df = df.set_axis(["Solcast", "Solcast_p10", "Solcast_p90"], axis=1) + confidence_level = self.get_config("solcast_confidence_level") + weighting = { + "Solcast_p10": max(50 - confidence_level, 0) / 40, + "Solcast": 1 - abs(confidence_level - 50) / 40, + "Solcast_p90": max(confidence_level - 50, 0) / 40, + } + + df["weighted"] = 0 + for w in weighting: + df["weighted"] += df[w] * weighting[w] + df *= 1000 df = df.fillna(0) # self.static = pd.concat([self.static, df], axis=1) diff --git a/apps/pv_opt/solis.py b/apps/pv_opt/solis.py index 9b3271f..eff4ea6 100644 --- a/apps/pv_opt/solis.py +++ b/apps/pv_opt/solis.py @@ -4,18 +4,35 @@ TIMEFORMAT = "%H:%M" INVERTER_DEFS = { "SOLIS_SOLAX_MODBUS": { - "modes": { - 1: "Selfuse - No Grid Charging", - 3: "Timed Charge/Discharge - No Grid Charging", - 17: "Backup/Reserve - No Grid Charging", - 33: "Selfuse", - 35: "Timed Charge/Discharge", - 37: "Off-Grid Mode", - 41: "Battery Awaken", - 43: "Battery Awaken + Timed Charge/Discharge", - 49: "Backup/Reserve - No Timed Charge/Discharge", - 51: "Backup/Reserve", + "codes": { + "SelfUse - No Grid Charging": 1, + "Self-Use - No Grid Charging": 1, + "Timed Charge/Discharge - No Grid Charging": 3, + "Backup/Reserve - No Grid Charging": 17, + "Self-Use": 33, + "SelfUse": 33, + "Timed Charge/Discharge": 35, + "Off-Grid Mode": 37, + "Battery Awaken": 41, + "Battery Awaken + Timed Charge/Discharge": 43, + "Backup/Reserve - No Timed Charge/Discharge": 49, + "Backup/Reserve": 51, + "Feed-in priority - No Grid Charging": 64, + "Feed-in priority - No Timed Charge/Discharge": 96, + "Feed-in priority": 98, }, + # "modes": { + # 1: "Self-Use - No Grid Charging", + # 3: "Timed Charge/Discharge - No Grid Charging", + # 17: "Backup/Reserve - No Grid Charging", + # 33: "Self-Use", + # 35: "Timed Charge/Discharge", + # 37: "Off-Grid Mode", + # 41: "Battery Awaken", + # 43: "Battery Awaken + Timed Charge/Discharge", + # 49: "Backup/Reserve - No Timed Charge/Discharge", + # 51: "Backup/Reserve", + # }, "bits": [ "SelfUse", "Timed", @@ -199,26 +216,15 @@ def __init__(self, inverter_type, host) -> None: ): for item in defs: if isinstance(defs[item], str): - conf[item] = defs[item].replace( - "{device_name}", self.host.device_name - ) + conf[item] = defs[item].replace("{device_name}", self.host.device_name) elif isinstance(defs[item], list): - conf[item] = [ - z.replace("{device_name}", self.host.device_name) - for z in defs[item] - ] + conf[item] = [z.replace("{device_name}", self.host.device_name) for z in defs[item]] else: conf[item] = defs[item] def enable_timed_mode(self): - if ( - self.type == "SOLIS_SOLAX_MODBUS" - or self.type == "SOLIS_CORE_MODBUS" - or self.type == "SOLIS_SOLARMAN" - ): - self._solis_set_mode_switch( - SelfUse=True, Timed=True, GridCharge=True, Backup=False - ) + if self.type == "SOLIS_SOLAX_MODBUS" or self.type == "SOLIS_CORE_MODBUS" or self.type == "SOLIS_SOLARMAN": + self._solis_set_mode_switch(SelfUse=True, Timed=True, GridCharge=True, Backup=False) def control_charge(self, enable, **kwargs): if enable: @@ -231,15 +237,9 @@ def control_discharge(self, enable, **kwargs): self._control_charge_discharge("discharge", enable, **kwargs) def hold_soc(self, enable, soc=None): - if ( - self.type == "SOLIS_SOLAX_MODBUS" - or self.type == "SOLIS_CORE_MODBUS" - or self.type == "SOLIS_SOLARMAN" - ): + if self.type == "SOLIS_SOLAX_MODBUS" or self.type == "SOLIS_CORE_MODBUS" or self.type == "SOLIS_SOLARMAN": if enable: - self._solis_set_mode_switch( - SelfUse=True, Timed=False, GridCharge=True, Backup=True - ) + self._solis_set_mode_switch(SelfUse=True, Timed=False, GridCharge=True, Backup=True) else: self.enable_timed_mode() @@ -253,9 +253,7 @@ def hold_soc(self, enable, soc=None): self.log(f"Setting Backup SOC to {soc}%") if self.type == "SOLIS_SOLAX_MODBUS": - changed, written = self._write_and_poll_value( - entity_id=entity_id, value=soc - ) + changed, written = self._write_and_poll_value(entity_id=entity_id, value=soc) elif self.type == "SOLIS_CORE_MODBUS" or self.type == "SOLIS_SOLARMAN": changed, written = self.solis_write_holding_register( address=INVERTER_DEFS(self.type)["registers"]["backup_mode_soc"], @@ -270,11 +268,7 @@ def hold_soc(self, enable, soc=None): @property def status(self): status = None - if ( - self.type == "SOLIS_SOLAX_MODBUS" - or self.type == "SOLIS_CORE_MODBUS" - or self.type == "SOLIS_SOLARMAN" - ): + if self.type == "SOLIS_SOLAX_MODBUS" or self.type == "SOLIS_CORE_MODBUS" or self.type == "SOLIS_SOLARMAN": status = self._solis_state() return status @@ -288,9 +282,7 @@ def _write_and_poll_value(self, entity_id, value, tolerance=0.0, verbose=False): if diff > tolerance: changed = True try: - self.host.call_service( - "number/set_value", entity_id=entity_id, value=value - ) + self.host.call_service("number/set_value", entity_id=entity_id, value=value) time.sleep(0.5) new_state = float(self.host.get_state(entity_id=entity_id)) @@ -310,11 +302,7 @@ def _monitor_target_soc(self, target_soc, mode="charge"): pass def _control_charge_discharge(self, direction, enable, **kwargs): - if ( - self.type == "SOLIS_SOLAX_MODBUS" - or self.type == "SOLIS_CORE_MODBUS" - or self.type == "SOLIS_SOLARMAN" - ): + if self.type == "SOLIS_SOLAX_MODBUS" or self.type == "SOLIS_CORE_MODBUS" or self.type == "SOLIS_SOLARMAN": self._solis_control_charge_discharge(direction, enable, **kwargs) def _solis_control_charge_discharge(self, direction, enable, **kwargs): @@ -366,16 +354,9 @@ def _solis_control_charge_discharge(self, direction, enable, **kwargs): value = times[limit].minute if self.type == "SOLIS_SOLAX_MODBUS": - changed, written = self._write_and_poll_value( - entity_id=entity_id, value=value, verbose=True - ) - elif ( - self.type == "SOLIS_CORE_MODBUS" - or self.type == "SOLIS_SOLARMAN" - ): - changed, written = self._solis_write_time_register( - direction, limit, unit, value - ) + changed, written = self._write_and_poll_value(entity_id=entity_id, value=value, verbose=True) + elif self.type == "SOLIS_CORE_MODBUS" or self.type == "SOLIS_SOLARMAN": + changed, written = self._solis_write_time_register(direction, limit, unit, value) else: e = "Unknown inverter type" @@ -384,9 +365,7 @@ def _solis_control_charge_discharge(self, direction, enable, **kwargs): if changed: if written: - self.log( - f"Wrote {direction} {limit} {unit} of {value} to inverter" - ) + self.log(f"Wrote {direction} {limit} {unit} of {value} to inverter") value_changed = True else: self.log( @@ -412,10 +391,7 @@ def _solis_control_charge_discharge(self, direction, enable, **kwargs): f"Failed to press button {entity_id}. Last pressed at {time_pressed.strftime(TIMEFORMAT)} ({dt:0.2f} seconds ago)" ) except: - self.log( - f"Failed to press button {entity_id}: it appears to never have been pressed." - ) - + self.log(f"Failed to press button {entity_id}: it appears to never have been pressed.") else: self.log("Inverter already at correct time settings") @@ -424,17 +400,11 @@ def _solis_control_charge_discharge(self, direction, enable, **kwargs): entity_id = self.host.config[f"id_timed_{direction}_current"] current = abs(round(power / self.host.get_config("battery_voltage"), 1)) - self.log( - f"Power {power:0.0f} = {current:0.1f}A at {self.host.get_config('battery_voltage')}V" - ) + self.log(f"Power {power:0.0f} = {current:0.1f}A at {self.host.get_config('battery_voltage')}V") if self.type == "SOLIS_SOLAX_MODBUS": - changed, written = self._write_and_poll_value( - entity_id=entity_id, value=current, tolerance=1 - ) + changed, written = self._write_and_poll_value(entity_id=entity_id, value=current, tolerance=1) elif self.type == "SOLIS_CORE_MODBUS" or self.type == "SOLIS_SOLARMAN": - changed, written = self._solis_write_current_register( - direction, current, tolerance=1 - ) + changed, written = self._solis_write_current_register(direction, current, tolerance=1) else: e = "Unknown inverter type" self.log(e, level="ERROR") @@ -469,28 +439,28 @@ def _solis_set_mode_switch(self, **kwargs): entity_id = self.host.config["id_inverter_mode"] if self.type == "SOLIS_SOLAX_MODBUS": - mode = INVERTER_DEFS[self.type]["modes"].get(code) + entity_modes = self.host.get_state(entity_id, attribute="options") + modes = {INVERTER_DEFS[self.type]["codes"].get(mode): mode for mode in entity_modes} + # mode = INVERTER_DEFS[self.type]["modes"].get(code) + mode = modes.get(code) if mode is not None: if self.host.get_state(entity_id=entity_id) != mode: - self.host.call_service( - "select/select_option", entity_id=entity_id, option=mode - ) + self.host.call_service("select/select_option", entity_id=entity_id, option=mode) self.log(f"Setting {entity_id} to {mode}") elif self.type == "SOLIS_CORE_MODBUS" or self.type == "SOLIS_SOLARMAN": address = INVERTER_DEFS[self.type]["registers"]["storage_control_switch"] - self._solis_write_holding_register( - address=address, value=code, entity_id=entity_id - ) + self._solis_write_holding_register(address=address, value=code, entity_id=entity_id) def _solis_solax_solarman_mode_switch(self): - modes = INVERTER_DEFS[self.type]["modes"] + inverter_mode = self.host.get_state(entity_id=self.host.config["id_inverter_mode"]) + if self.type == "SOLIS_SOLAX_MODBUS": + code = INVERTER_DEFS[self.type]["codes"][inverter_mode] + else: + modes = INVERTER_DEFS[self.type]["modes"] + code = {modes[m]: m for m in modes}[inverter_mode] + bits = INVERTER_DEFS[self.type]["bits"] - codes = {modes[m]: m for m in modes} - inverter_mode = self.host.get_state( - entity_id=self.host.config["id_inverter_mode"] - ) - code = codes[inverter_mode] switches = {bit: (code & 2**i == 2**i) for i, bit in enumerate(bits)} return {"mode": inverter_mode, "code": code, "switches": switches} @@ -578,9 +548,7 @@ def _solis_write_holding_register( if changed: data = {"register": address, "value": value} # self.host.call_service("solarman/write_holding_register", **data) - self.log( - ">>> Writing {value} to inverter register {address} using Solarman" - ) + self.log(">>> Writing {value} to inverter register {address} using Solarman") written = True return changed, written @@ -597,11 +565,7 @@ def _solis_write_current_register(self, direction, current, tolerance): ) def _solis_write_time_register(self, direction, limit, unit, value): - address = INVERTER_DEFS[self.type]["registers"][ - f"timed_{direction}_{limit}_{unit}" - ] + address = INVERTER_DEFS[self.type]["registers"][f"timed_{direction}_{limit}_{unit}"] entity_id = self.host.config[f"id_timed_{direction}_{limit}_{unit}"] - return self._solis_write_holding_register( - address=address, value=value, entity_id=entity_id - ) + return self._solis_write_holding_register(address=address, value=value, entity_id=entity_id) diff --git a/pvopt_dashboard.yaml b/pvopt_dashboard.yaml index 4f9b994..adb7c7a 100644 --- a/pvopt_dashboard.yaml +++ b/pvopt_dashboard.yaml @@ -124,8 +124,8 @@ views: name: Allow Cyclic Charge/Discharge - entity: switch.pvopt_read_only name: Read Only Mode - - entity: select.pvopt_solar_forecast - name: Solar Forecast to Use + - entity: number.pvopt_solcast_confidence_level + name: Solcast Confidence Level - entity: number.pvopt_optimise_frequency_minutes name: Optimiser Freq (mins) - type: markdown