diff --git a/README.md b/README.md index cea6851..2a59528 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# PV Opt: Home Assistant Solar/Battery Optimiser v3.13.2 +# PV Opt: Home Assistant Solar/Battery Optimiser v3.14.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 609d524..b3090e4 100644 --- a/apps/pv_opt/config/config.yaml +++ b/apps/pv_opt/config/config.yaml @@ -20,14 +20,13 @@ pv_opt: log: pv_opt_log prefix: pvopt - # debug: True - + debug: false # User configuration --- EDIT AWAY! --- - inverter_type: "SOLIS_SOLAX_MODBUS" - # inverter_type: "SOLIS_CORE_MODBUS" - # inverter_type: SOLIS_SOLARMAN - device_name: solis + # ======================================== + # System parameters + # ======================================== + # If true the current config in HA will be over-written with that in the config.yaml. overwrite_ha_on_restart: true @@ -57,6 +56,7 @@ pv_opt: # charger_power_watts: 3500 # inverter_power_watts: 3600 # inverter_loss_watts: 100 + # battery_current_limit_amps: 50 # ======================================== # Solcast configuration @@ -72,7 +72,7 @@ pv_opt: # ======================================== # # use_consumption_history: true - consumption_history_days: 2 + consumption_history_days: 6 # # daily_consumption_kwh: 17 # shape_consumption_profile: true @@ -104,11 +104,18 @@ pv_opt: # # These are the default entities used with the Solis Solax Modbus integration. You can change them here and over-ride the defaults + # inverter_type: SOLIS_SOLAX_MODBUS + # device_name: solis + # battery_voltage: sensor.{device_name}_battery_voltage # update_cycle_seconds: 15 # maximum_dod_percent: number.{device_name}_battery_minimum_soc - # id_consumption_today: sensor.{device_name}_consumption_today + id_consumption_today: sensor.{device_name}_consumption_today + id_consumption: + - sensor.{device_name}_house_load + - sensor.{device_name}_bypass_load + # id_grid_import_today: sensor.{device_name}_grid_import_today # id_grid_export_today: sensor.{device_name}_grid_export_today @@ -134,12 +141,18 @@ pv_opt: # # These are the default entities used with the Solis Core Modbus integration. You can change them here and over-ride the defaults + # inverter_type: SOLIS_CORE_MODBUS + # device_name: solis + # modbus_hub: "{device_name}" # modbus_slave: 1 # battery_voltage: sensor.{device_name}_battery_voltage # maximum_dod_percent: sensor.{device_name}_overdischarge_soc # update_cycle_seconds: 60 # id_consumption_today: sensor.{device_name}_daily_consumption + # id_consumption: + # - sensor.{device_name}_house_load_power + # - sensor.{device_name}_backup_load_power # id_grid_power: sensor.{device_name}_grid_active_power # id_inverter_ac_power: sensor.{device_name}_inverter_ac_power @@ -165,6 +178,9 @@ pv_opt: # # These are the default entities used with the Solis Solarman integration. You can change them here and over-ride the defaults + # inverter_type: SOLIS_SOLARMAN + # device_name: solis + # battery_voltage: sensor.{device_name}_battery_voltage # maximum_dod_percent: 15 # update_cycle_seconds: 60 @@ -190,9 +206,28 @@ pv_opt: # id_inverter_mode: sensor.{device_name}_storage_control_mode + # =============================================================================================================== + # Brand / Integration Specific Config: SUNSYNK_SOLARSYNK2: + # =============================================================================================================== + # + # + # These are the default entities used with the Sunsynk Solarsynk2 integration. You can change them here and over-ride the defaults + + # inverter_type: SUNSYNK_SOLARSYNK2 + # device_name: solarsynk + # inverter_sn: 000000 # enter {sunsynk_serial} from solarsynk2 addon + + # maximum_dod_percent: 20 + # id_battery_soc: sensor.{device_name}_{inverter_sn}_battery_soc + # id_consumption_today: sensor.{device_name}_{inverter_sn}_day_load_energy + # id_grid_import_today: sensor.{device_name}_{inverter_sn}_day_grid_import + # id_grid_export_today: sensor.{device_name}_{inverter_sn}_day_grid_export + # supports_hold_soc: false + # update_cycle_seconds: 300 # Tariff comparison - id_daily_solar: sensor.{device_name}_power_generation_today + # id_daily_solar: sensor.{device_name}_power_generation_today + id_solar_power: sensor.{device_name}_pv_total_power alt_tariffs: - name: Agile_Fix octopus_import_tariff_code: E-1R-AGILE-23-12-06-G @@ -201,6 +236,7 @@ pv_opt: - name: Eco7_Fix octopus_import_tariff_code: E-2R-VAR-22-11-01-G octopus_export_tariff_code: E-1R-OUTGOING-FIX-12M-19-05-13-G + - name: Flux octopus_import_tariff_code: E-1R-FLUX-IMPORT-23-02-14-G octopus_export_tariff_code: E-1R-FLUX-EXPORT-23-02-14-G \ No newline at end of file diff --git a/apps/pv_opt/pv_opt.code-workspace b/apps/pv_opt/pv_opt.code-workspace new file mode 100644 index 0000000..05a2dde --- /dev/null +++ b/apps/pv_opt/pv_opt.code-workspace @@ -0,0 +1,11 @@ +{ + "folders": [ + { + "path": "../.." + }, + { + "path": "W:/logs" + } + ], + "settings": {} +} \ No newline at end of file diff --git a/apps/pv_opt/pv_opt.py b/apps/pv_opt/pv_opt.py index 0c84b8c..3725a6b 100644 --- a/apps/pv_opt/pv_opt.py +++ b/apps/pv_opt/pv_opt.py @@ -12,7 +12,7 @@ from numpy import nan import re -VERSION = "3.13.2" +VERSION = "3.14.0" OCTOPUS_PRODUCT_URL = r"https://api.octopus.energy/v1/products/" @@ -24,7 +24,9 @@ REDACT_REGEX = [ "[0-9]{2}m[0-9]{7}_[0-9]{13}", # Serial_MPAN + "[0-9]{2}e[0-9]{7}_[0-9]{13}", # Serial_MPAN "[0-9]{2}m[0-9]{7}", # Serial + "[0-9]{2}e[0-9]{7}", # Serial "^$|\d{13}$", # MPAN "a_[0-f]{8}", # Account Number "A-[0-f]{8}", # Account Number @@ -40,6 +42,7 @@ OVERWRITE_ATTEMPTS = 5 ONLINE_RETRIES = 12 WRITE_POLL_SLEEP = 0.5 +WRITE_POLL_RETRIES = 5 GET_STATE_RETRIES = 5 GET_STATE_WAIT = 0.5 @@ -54,7 +57,7 @@ "consumption": [300, 200, 150, 500, 500, 750, 750, 300], } -INVERTER_TYPES = ["SOLIS_SOLAX_MODBUS", "SOLIS_CORE_MODBUS", "SOLIS_SOLARMAN"] +INVERTER_TYPES = ["SOLIS_SOLAX_MODBUS", "SOLIS_CORE_MODBUS", "SOLIS_SOLARMAN", "SUNSYNK_SOLARSYNK2", "SOLAX_X1"] SYSTEM_ARGS = [ "module", @@ -80,6 +83,9 @@ "number": { "mode": "slider", }, + "text": { + "pattern": "^([0-1]?[0-9]|2[0-3]):[0-5][0-9]", + }, } DOMAIN_ATTRIBUTES = { @@ -89,8 +95,9 @@ } DEFAULT_CONFIG = { - "forced_discharge": {"default": True, "domain": "switch"}, "read_only": {"default": True, "domain": "switch"}, + "include_export": {"default": True, "domain": "switch"}, + "forced_discharge": {"default": True, "domain": "switch"}, "allow_cyclic": {"default": False, "domain": "switch"}, "use_solar": {"default": True, "domain": "switch"}, "optimise_frequency_minutes": { @@ -103,6 +110,47 @@ }, "domain": "number", }, + "test_start": {"default": "00:00", "domain": "text", "min": 5, "max": 5}, + "test_end": {"default": "00:00", "domain": "text", "min": 5, "max": 5}, + "test_power": { + "default": 3000, + "domain": "number", + "attributes": { + "min": 1000, + "max": 10000, + "step": 100, + "unit_of_measurement": "W", + "device_class": "power", + "mode": "slider", + }, + }, + "test_target_soc": { + "default": 100, + "domain": "number", + "attributes": { + "min": 0, + "max": 100, + "step": 1, + "unit_of_measurement": "%", + "device_class": "battery", + "mode": "slider", + }, + }, + "test_enable": { + "default": "Enable", + "domain": "select", + "attributes": {"options": ["Enable", "Disable"]}, + }, + "test_function": { + "default": "Charge", + "domain": "select", + "attributes": {"options": ["Charge", "Discharge"]}, + }, + "test_button": { + "default": pd.Timestamp.now(tz="UTC"), + "name": "Test", + "domain": "button", + }, "solcast_confidence_level": { "default": 50, "attributes": { @@ -144,7 +192,7 @@ "domain": "number", }, "plunge_threshold_p_kwh": { - "default": 2.0, + "default": -5.0, "attributes": { "min": -5.0, "max": 10.0, @@ -234,6 +282,53 @@ "mode": "slider", }, }, + "battery_current_limit_amps": { + "default": 100, + "domain": "number", + "attributes": { + "min": 0, + "max": 300, + "step": 10, + "unit_of_measurement": "A", + "device_class": "current", + "mode": "slider", + }, + }, + "ev_charger_power_watts": { + "default": 7000, + "domain": "number", + "attributes": { + "min": 1000, + "max": 10000, + "step": 100, + "unit_of_measurement": "W", + "device_class": "power", + "mode": "slider", + }, + }, + "ev_battery_capacity_kwh": { + "default": 30, + "domain": "number", + "attributes": { + "min": 5, + "max": 600, + "step": 1, + "unit_of_measurement": "kWh", + "device_class": "energy", + "mode": "slider", + }, + }, + "ev_charger": { + "default": "None", + "attributes": { + "options": [ + "None", + "Zappi", + "Other", + ] + }, + "domain": "select", + }, "solar_forecast": { "default": "Solcast", "attributes": {"options": ["Solcast", "Solcast_p10", "Solcast_p90", "Weighted"]}, @@ -292,6 +387,9 @@ }, }, # "alt_tariffs": {"default": [], "domain": "input_select"}, + "charge_active": {"default": True, "domain": "switch"}, + "discharge_active": {"default": True, "domain": "switch"}, + "hold_soc_active": {"default": True, "domain": "switch"}, } @@ -305,6 +403,7 @@ def importName(modulename, name): class PVOpt(hass.Hass): + @ad.app_lock def initialize(self): self.config = {} self.log("") @@ -333,6 +432,11 @@ def initialize(self): self.redact = self.args.pop("redact_personal_data_from_log", True) + self.inverter_sn = self.args.pop("inverter_sn", "") + if self.inverter_sn != "": + self.redact_regex.append(self.inverter_sn) + + self.redact = self.args.pop("redact_personal_data_from_log", True) self._load_inverter() retry_count = 0 @@ -358,8 +462,11 @@ def initialize(self): self.handles = {} self.mqtt_handles = {} + self.mpans = [] + self.saving_events = {} self.contract = None + self.bottlecap_entities = {"import": None, "export": None} # Load arguments from the YAML file @@ -371,13 +478,14 @@ def initialize(self): # self._estimate_capacity() self._load_pv_system_model() self._load_contract() + self._check_for_zappi() if self.get_config("alt_tariffs") is not None: self._compare_tariffs() self._setup_compare_schedule() - if self.agile: - self._setup_agile_schedule() + # if self.agile: + # self._setup_agile_schedule() self._cost_actual() @@ -400,6 +508,94 @@ def initialize(self): for id in self.handles: self.log(f" {id} {self.handles[id]} {self.info_listen_state(self.handles[id])}") + @ad.app_lock + def _run_test(self): + self.ulog("Test") + + test = { + item: self.get_ha_value(self.ha_entities[f"test_{item}"]) + for item in ["start", "end", "power", "enable", "function", "target_soc"] + } + + for x in ["start", "end"]: + test[x] = pd.Timestamp(test[x], tz=self.tz) + + test["enable"] = test["enable"].lower() == "enable" + function = test.pop("function").lower() + + self._log_inverter_status(self.inverter.status) + + if function == "charge": + self.inverter.control_charge(**test) + + elif function == "discharge": + self.inverter.control_discharge(**test) + + else: + pass + + if self.get_config("update_cycle_seconds") is not None: + i = int(self.get_config("update_cycle_seconds") * 1.2) + self.log(f"Waiting for Modbus Read cycle: {i} seconds") + while i > 0: + self._status(f"Waiting for Modbus Read cycle: {i}") + time.sleep(1) + i -= 1 + + self._log_inverter_status(self.inverter.status) + + def _check_for_io(self): + self.ulog("Checking for Intelligent Octopus") + entity_id = f"binary_sensor.octopus_energy_{self.get_config('octopus_account').lower().replace('-', '_')}_intelligent_dispatching" + self.rlog(f">>> {entity_id}") + io_dispatches = self.get_state(entity_id) + self.log(f">>> IO entity state: {io_dispatches}") + self.io = io_dispatches is not None + if self.io: + self.rlog(f"IO entity {entity_id} found") + self.log("") + self.io_entity = entity_id + + def _get_io(self): + self.ulog("Intelligent Octopus Status") + self.io_dispatch_active = self.get_state(self.io_entity) + self.log(f" Active: {self.io_dispatch_active}") + self.log("") + self.io_dispatch_attrib = self.get_state(self.io_entity, attribute="all") + for k in [x for x in self.io_dispatch_attrib.keys() if "dispatches" not in x]: + self.log(f" {k:20s} {self.io_dispatch_attrib[k]}") + + for k in [x for x in self.io_dispatch_attrib.keys() if "dispatches" in x]: + self.log(f" {k:20s} {'Start':20s} {'End':20s} {'Charge':12s} {'Source':12s}") + self.log(f" {'-'*20} {'-'*20} {'-'*20} {'-'*12} {'-'*12} ") + for z in self.io_dispatch_attrib[k]: + self.log( + f" {z['start'].strftime(DATE_TIME_FORMAT_LONG):20s} {z['end'].strftime(DATE_TIME_FORMAT_LONG):20s} {z['charge_in_kwh']:12.3f} {z['source']:12s}" + ) + self.log("") + + def _check_for_zappi(self): + self.ulog("Checking for Zappi Sensors") + sensor_entities = self.get_state("sensor") + self.zappi_entities = [k for k in sensor_entities if "zappi" in k for x in ["charge_added_session"] if x in k] + if len(self.zappi_entities) > 0: + for entity_id in self.zappi_entities: + zappi_sn = entity_id.split("_")[2] + self.redact_regex.append(zappi_sn) + self.rlog(f" {entity_id}") + else: + self.log("No Zappi sensors found") + self.log("") + + def _get_zappi(self, start, end, log=False): + df = pd.DataFrame() + for entity_id in self.zappi_entities: + df += self._get_hass_power_from_daily_kwh(entity_id, start=start, end=end, log=log) + if log: + self.rlog(f">>> Zappi entity {entity_id}") + self.log(f">>>\n{df.to_string()}") + return df + def rlog(self, str, **kwargs): if self.redact: try: @@ -453,6 +649,8 @@ def _load_inverter(self): InverterController = importName(f"{inverter_brand}", "InverterController") self.log(f"Inverter type: {self.inverter_type}: inverter module: {inverter_brand}.py") self.inverter = InverterController(inverter_type=self.inverter_type, host=self) + self.log(f" Device name: {self.device_name}") + self.log(f" Serial number: {self.inverter_sn}") else: e = f"Inverter type {self.inverter_type} is not yet supported. Only read-only mode with explicit config from the YAML will work." @@ -475,16 +673,16 @@ def _load_pv_system_model(self): ) self.pv_system = pv.PVsystemModel("PV_Opt", self.inverter_model, self.battery_model, host=self) - def _setup_agile_schedule(self): - start = (pd.Timestamp.now(tz="UTC") + pd.Timedelta(1, "minutes")).to_pydatetime() - self.timer_handle = self.run_every( - self._load_agile_cb, - start=start, - interval=3600, - ) + # def _setup_agile_schedule(self): + # start = (pd.Timestamp.now(tz="UTC") + pd.Timedelta(1, "minutes")).to_pydatetime() + # self.timer_handle = self.run_every( + # self._load_agile_cb, + # start=start, + # interval=3600, + # ) def _setup_compare_schedule(self): - start = (pd.Timestamp.now(tz="UTC").normalize() + pd.Timedelta(hours=25, minutes=1)).to_pydatetime() + start = (pd.Timestamp.now(tz="UTC").ceil("60min") - pd.Timedelta("2min")).to_pydatetime() self.timer_handle = self.run_every( self._compare_tariff_cb, start=start, @@ -524,22 +722,6 @@ def _cost_actual(self, **kwargs): def _compare_tariff_cb(self, cb_args): self._compare_tariffs() - @ad.app_lock - def _load_agile_cb(self, cb_args): - # reload if the time is after 16:00 and the last data we have is today - if self.debug: - self.log(">>> Agile Callback Handler") - self.log( - f">>> Contract end day: {self.contract.tariffs['import'].end().day:2d} Today:{pd.Timestamp.now().day:2d} {(self.contract.tariffs['import'].end().day == pd.Timestamp.now().day)}" - ) - self.log(f">>> Current hour: {pd.Timestamp.now().hour:2d} {pd.Timestamp.now().hour > 16}") - if (self.contract.tariffs["import"].end().day == pd.Timestamp.now().day) and (pd.Timestamp.now().hour > 16): - self.log(f"Contract end day: {self.contract.tariffs['import'].end().day} Today:{pd.Timestamp.now().day}") - self._load_contract() - - elif pd.Timestamp.now(tz="UTC").hour == 0: - self._load_contract() - def get_config(self, item, default=None): if item in self.config_state: return self._value_from_state(self.config_state[item]) @@ -564,14 +746,14 @@ def get_config(self, item, default=None): return default def _setup_schedule(self): - start_opt = pd.Timestamp.now().ceil(f"{self.get_config('optimise_frequency_minutes')}T").to_pydatetime() + start_opt = pd.Timestamp.now().ceil(f"{self.get_config('optimise_frequency_minutes')}min").to_pydatetime() self.timer_handle = self.run_every( self.optimise_time, start=start_opt, interval=self.get_config("optimise_frequency_minutes") * 60, ) self.log( - f"Optimiser will run every {self.get_config('optimise_frequency_minutes')} minutes from {start_opt.strftime('%H:%M')} or on {EVENT_TRIGGER} Event" + f"Optimiser will run every {self.get_config('optimise_frequency_minutes')} minutes from {start_opt.strftime('%H:%M %Z')} or on {EVENT_TRIGGER} Event" ) def _load_contract(self): @@ -730,11 +912,14 @@ def _load_contract(self): self.contract = old_contract else: + self.contract_last_loaded = pd.Timestamp.now(tz="UTC") if self.contract.tariffs["export"] is None: self.contract.tariffs["export"] = pv.Tariff("None", export=True, unit=0, octopus=False, host=self) self.rlog("") self._load_saving_events() + self._check_for_io() + self.rlog("Finished loading contract") def _check_tariffs(self): @@ -912,9 +1097,16 @@ def _load_args(self, items=None): # if the item starts with 'id_' then it must be an entity that exists: elif item == "alt_tariffs": self.config[item] = values - self.rlog( - f" {item:34s} = {str(self.config[item]):57s} {str(self.get_config(item)):>6s}: value(s) in YAML" - ) + for i, x in enumerate(values): + if i == 0: + str1 = item + str2 = "=" + else: + str1 = "" + str2 = " " + + self.rlog(f" {str1:34s} {str2} {x['name']:27s} Import: {x['octopus_import_tariff_code']:>36s}") + self.rlog(f" {'':34s} {'':27s} Export: {x['octopus_export_tariff_code']:>36s}") self.yaml_config[item] = self.config[item] elif "id_" in item: @@ -1097,8 +1289,6 @@ def _load_args(self, items=None): self.rlog("") - # self.rlog(f">>> {self.yaml_config}") - self._expose_configs(over_write) def _name_from_item(self, item): @@ -1125,8 +1315,10 @@ def _expose_configs(self, over_write=True): for item in DEFAULT_CONFIG if (item not in [self.change_items[entity] for entity in self.change_items]) and ("id_" not in item) + and ("json_" not in item) and ("alt_" not in item) and ("auto" not in item) + and ("active" not in item) and "domain" in DEFAULT_CONFIG[item] ] @@ -1169,9 +1361,12 @@ def _expose_configs(self, over_write=True): self.mqtt.mqtt_subscribe(state_topic) - elif isinstance(self.get_ha_value(entity_id), str) and self.get_ha_value(entity_id) not in attributes.get( - "options", {} + elif ( + isinstance(self.get_ha_value(entity_id), str) + and (self.get_ha_value(entity_id) not in attributes.get("options", {})) + and (domain not in ["text", "button"]) ): + state = self._state_from_value(self.get_default_config(item)) self.log(f" - Found unexpected str for {entity_id} reverting to default of {state}") @@ -1220,14 +1415,15 @@ def _expose_configs(self, over_write=True): self.log(f" {'Config Item':40s} {'HA Entity':42s} Current State") self.log(f" {'-----------':40s} {'---------':42s} -------------") + self.ha_entities = {} for entity_id in self.change_items: if not "sensor" in entity_id: - self.log( - f" {self.change_items[entity_id]:40s} {entity_id:42s} {self.config_state[self.change_items[entity_id]]}" - ) + item = self.change_items[entity_id] + self.log(f" {item:40s} {entity_id:42s} {self.config_state[item]}") self.handles[entity_id] = self.listen_state( callback=self.optimise_state_change, entity_id=entity_id ) + self.ha_entities[item] = entity_id self.mqtt.listen_state( callback=self.optimise_state_change, @@ -1258,7 +1454,10 @@ def optimise_state_change(self, entity_id, attribute, old, new, kwargs): ]: self._load_pv_system_model() - self.optimise() + if "test" not in item: + self.optimise() + elif "button" in item: + self._run_test() def _value_from_state(self, state): value = None @@ -1304,7 +1503,10 @@ def optimise(self): self.log("") self._load_saving_events() - if self.get_config("forced_discharge"): + if self.io: + self._get_io() + + if self.get_config("forced_discharge") and (self.get_config("supports_forced_discharge", True)): discharge_enable = "enabled" else: discharge_enable = "disabled" @@ -1313,9 +1515,21 @@ def optimise(self): self.log(f"Starting Opimisation with discharge {discharge_enable}") self.log(f"------------------------------------{len(discharge_enable)*'-'}") - self.log("") - self.log("Checking tariffs:") - self.log("-----------------") + self.ulog("Checking tariffs:") + + self.log(f" Contract last loaded at {self.contract_last_loaded.strftime(DATE_TIME_FORMAT_SHORT)}") + + if self.agile: + if (self.contract.tariffs["import"].end().day == pd.Timestamp.now().day) and ( + pd.Timestamp.now(tz=self.tz).hour >= 16 + ): + self.log( + f"Contract end day: {self.contract.tariffs['import'].end().day} Today:{pd.Timestamp.now().day}" + ) + self._load_contract() + + elif self.contract_last_loaded.day != pd.Timestamp.now(tz="UTC").day: + self._load_contract() if self._check_tariffs(): self.log("") @@ -1326,6 +1540,9 @@ def optimise(self): self.log(" Tariffs OK") self.log("") + if self.io: + self._get_io + self.t0 = pd.Timestamp.now() self.static = pd.DataFrame( index=pd.date_range( @@ -1358,7 +1575,7 @@ def optimise(self): self.time_now = pd.Timestamp.utcnow() self.static = self.static[self.time_now.floor("30min") :].fillna(0) - # self.load_consumption() + self.soc_now = self.get_config("id_battery_soc") x = self.hass2df(self.config["id_battery_soc"], days=1, log=self.debug) if self.debug: @@ -1400,22 +1617,23 @@ def optimise(self): self.log("") self.log(f"Initial SOC: {self.initial_soc}") - self.base = self.pv_system.flows( - self.initial_soc, - self.static, - # solar="self.get_config("solar_forecast")", - solar="weighted", - ) - + self.flows = { + "Base": self.pv_system.flows( + self.initial_soc, + self.static, + solar="weighted", + ) + } self.log("Calculating Base flows:") - if len(self.base) == 0: + + if len(self.flows["Base"]) == 0: self.log("") self.log(" Unable to calculate baseline perfoormance", level="ERROR") self._status("ERROR: Baseline performance") return - self.base_cost = self.contract.net_cost(self.base) - self.log(f" Base cost: {self.base_cost.sum():6.2f}p") + self.optimised_cost = {"Base": self.contract.net_cost(self.flows["Base"])} + self.log("") if self.get_config("use_solar", True): str_log = ( @@ -1429,18 +1647,63 @@ def optimise(self): + f" from {self.static.index[0].strftime(DATE_TIME_FORMAT_SHORT)} to {self.static.index[-1].strftime(DATE_TIME_FORMAT_SHORT)}" ) + cases = { + "Optimised Charging": { + "export": False, + "discharge": False, + }, + "Optimised PV Export": { + "export": True, + "discharge": False, + }, + "Forced Discharge": { + "export": True, + "discharge": True, + }, + } + + if not self.get_config("include_export"): + self.selected_case = "Optimised Charging" + + elif not self.get_config("forced_discharge"): + self.selected_case = "Optimised PV Export" + + else: + self.selected_case = "Forced Discharge" + self._status("Optimising charge plan") - self.opt = self.pv_system.optimised_force( - self.initial_soc, - self.static, - self.contract, - # solar="self.get_config("solar_forecast")", - solar="weighted", - discharge=self.get_config("forced_discharge"), - max_iters=MAX_ITERS, - ) - self.opt_cost = self.contract.net_cost(self.opt) + for case in cases: + self.flows[case] = self.pv_system.optimised_force( + self.initial_soc, + self.static, + self.contract, + solar="weighted", + export=cases[case]["export"], + discharge=cases[case]["discharge"], + log=(case == self.selected_case), + max_iters=MAX_ITERS, + ) + + self.optimised_cost[case] = self.contract.net_cost(self.flows[case]) + + self.ulog("Optimisation Summary") + self.log(f" {'Base cost:':40s} {self.optimised_cost['Base'].sum():6.1f}p") + cost_today = self._cost_actual().sum() + self.summary_costs = { + "Base": {"cost": ((self.optimised_cost["Base"].sum() + cost_today) / 100).round(2), "Selected": ""} + } + for case in cases: + str_log = f" {f'Optimised cost ({case}):':40s} {self.optimised_cost[case].sum():6.1f}p" + self.summary_costs[case] = {"cost": ((self.optimised_cost[case].sum() + cost_today) / 100).round(2)} + if case == self.selected_case: + self.summary_costs[case]["Selected"] = " <=== Current Setup" + else: + self.summary_costs[case]["Selected"] = "" + + self.log(str_log + self.summary_costs[case]["Selected"]) + + self.opt = self.flows[self.selected_case] self.log("") @@ -1448,7 +1711,7 @@ def optimise(self): self.log("") self.log( - f"Plan time: {self.static.index[0].strftime('%d-%b %H:%M')} - {self.static.index[-1].strftime('%d-%b %H:%M')} Initial SOC: {self.initial_soc} Base Cost: {self.base_cost.sum():5.2f} Opt Cost: {self.opt_cost.sum():5.2f}" + f"Plan time: {self.static.index[0].strftime('%d-%b %H:%M')} - {self.static.index[-1].strftime('%d-%b %H:%M')} Initial SOC: {self.initial_soc} Base Cost: {self.optimised_cost['Base'].sum():5.1f} Opt Cost: {self.optimised_cost[self.selected_case].sum():5.1f}" ) self.log("") optimiser_elapsed = round((pd.Timestamp.now() - self.t0).total_seconds(), 1) @@ -1502,22 +1765,26 @@ def optimise(self): self.log("No charge/discharge windows planned.") if self.charge_power > 0: + self.inverter.control_discharge(enable=False) + self.inverter.control_charge( enable=True, start=self.charge_start_datetime, end=self.charge_end_datetime, power=self.charge_power, + target_soc=self.charge_target_soc, ) - self.inverter.control_discharge(enable=False) elif self.charge_power < 0: + self.inverter.control_charge(enable=False) + self.inverter.control_discharge( enable=True, start=self.charge_start_datetime, end=self.charge_end_datetime, power=self.charge_power, + target_soc=self.charge_target_soc, ) - self.inverter.control_charge(enable=False) elif ( (time_to_slot_start <= 0) @@ -1531,7 +1798,12 @@ def optimise(self): if self.hold and self.hold[0]["active"]: if not status["hold_soc"]["active"] or status["hold_soc"]["soc"] != self.hold[0]["soc"]: self.log(f" Enabling SOC hold at SOC of {self.hold[0]['soc']:0.0f}%") - self.inverter.hold_soc(enable=True, soc=self.hold[0]["soc"]) + self.inverter.hold_soc( + enable=True, + soc=self.hold[0]["soc"], + start=self.charge_start_datetime, + end=self.charge_end_datetime, + ) else: self.log(f" Inverter already holding SOC of {self.hold[0]['soc']:0.0f}%") @@ -1540,40 +1812,42 @@ def optimise(self): if self.charge_power > 0: if not status["charge"]["active"]: - start = pd.Timestamp.now() + start = pd.Timestamp.now(tz=self.tz) else: start = None + if status["discharge"]["active"]: + self.inverter.control_discharge( + enable=False, + ) + self.inverter.control_charge( enable=True, start=start, end=self.charge_end_datetime, power=self.charge_power, + target_soc=self.charge_target_soc, ) - if status["discharge"]["active"]: - self.inverter.control_discharge( - enable=False, - ) - elif self.charge_power < 0: if not status["discharge"]["active"]: - start = pd.Timestamp.now() + start = pd.Timestamp.now(tz=self.tz) else: start = None + if status["charge"]["active"]: + self.inverter.control_charge( + enable=False, + ) + self.inverter.control_discharge( enable=True, start=start, end=self.charge_end_datetime, power=self.charge_power, + target_soc=self.charge_target_soc, ) - if status["charge"]["active"]: - self.inverter.control_charge( - enable=False, - ) - else: if self.charge_power > 0: direction = "charge" @@ -1599,6 +1873,7 @@ def optimise(self): self.log(str_log) self.inverter.control_charge(enable=False) did_something = True + elif status["charge"]["start"] != status["charge"]["end"]: str_log += " but charge start and end times are different." self.log(str_log) @@ -1610,6 +1885,7 @@ def optimise(self): self.log(str_log) self.inverter.control_discharge(enable=False) did_something = True + elif status["discharge"]["start"] != status["discharge"]["end"]: str_log += " but charge start and end times are different." self.log(str_log) @@ -1650,9 +1926,9 @@ def optimise(self): if did_something: if self.get_config("update_cycle_seconds") is not None: i = int(self.get_config("update_cycle_seconds") * 1.2) - self.log(f"Waiting for Modbus Read cycle: {i} seconds") + self.log(f"Waiting for inverter Read cycle: {i} seconds") while i > 0: - self._status(f"Waiting for Modbus Read cycle: {i}") + self._status(f"Waiting for inverter Read cycle: {i}") time.sleep(1) i -= 1 @@ -1692,8 +1968,8 @@ def _create_windows(self): self.opt["period"] = (self.opt["forced"].diff() > 0).cumsum() if (self.opt["forced"] != 0).sum() > 0: x = self.opt[self.opt["forced"] > 0].copy() - x["start"] = x.index - x["end"] = x.index + pd.Timedelta(30, "minutes") + x["start"] = x.index.tz_convert(self.tz) + x["end"] = x.index.tz_convert(self.tz) + pd.Timedelta(30, "minutes") x["soc"] = x["soc"].round(0).astype(int) x["soc_end"] = x["soc_end"].round(0).astype(int) windows = pd.concat( @@ -1705,8 +1981,8 @@ def _create_windows(self): ) x = self.opt[self.opt["forced"] < 0].copy() - x["start"] = x.index - x["end"] = x.index + pd.Timedelta(30, "minutes") + x["start"] = x.index.tz_convert(self.tz) + x["end"] = x.index.tz_convert(self.tz) + pd.Timedelta(30, "minutes") self.windows = pd.concat( [ x.groupby("period").first()[["start", "soc", "forced"]], @@ -1741,8 +2017,9 @@ def _create_windows(self): self.charge_power = self.windows["forced"].iloc[0] self.charge_current = self.charge_power / self.get_config("battery_voltage", default=50) - self.charge_start_datetime = self.windows["start"].iloc[0] - self.charge_end_datetime = self.windows["end"].iloc[0] + self.charge_start_datetime = self.windows["start"].iloc[0].tz_convert(self.tz) + self.charge_end_datetime = self.windows["end"].iloc[0].tz_convert(self.tz) + self.charge_target_soc = self.windows["soc_end"].iloc[0] self.hold = [ { "active": self.windows["hold_soc"].iloc[i] == "<=", @@ -1755,8 +2032,9 @@ def _create_windows(self): self.log(f"No charging slots") self.charge_current = 0 self.charge_power = 0 - self.charge_start_datetime = self.static.index[0] - self.charge_end_datetime = self.static.index[0] + self.charge_target_soc = 0 + self.charge_start_datetime = self.static.index[0].tz_convert(self.tz) + self.charge_end_datetime = self.static.index[0].tz_convert(self.tz) self.hold = [] self.windows = pd.DataFrame() @@ -1790,6 +2068,7 @@ def write_cost( entity, cost, df, + attributes={}, ): cost_today = self._cost_actual() midnight = pd.Timestamp.now(tz="UTC").normalize() + pd.Timedelta(24, "hours") @@ -1826,6 +2105,7 @@ def write_cost( } | {col: df[["period_start", col]].to_dict("records") for col in cols if col in df.columns} | {"cost": cost[["period_start", "cumulative_cost"]].to_dict("records")} + | attributes ) self.write_to_hass( @@ -1856,15 +2136,16 @@ def _write_output(self): self.write_cost( "PV Opt Base Cost", entity=f"sensor.{self.prefix}_base_cost", - cost=self.base_cost, - df=self.base, + cost=self.optimised_cost["Base"], + df=self.flows["Base"], ) self.write_cost( "PV Opt Optimised Cost", entity=f"sensor.{self.prefix}_opt_cost", - cost=self.opt_cost, - df=self.opt, + cost=self.optimised_cost[self.selected_case], + df=self.flows[self.selected_case], + attributes={"Summary": self.summary_costs}, ) self.write_to_hass( @@ -1988,11 +2269,14 @@ def _get_hass_power_from_daily_kwh(self, entity_id, start=None, end=None, days=N log=log, ) - # self.log(df.to_string()) if df is not None: df.index = pd.to_datetime(df.index) - df = (df.diff(-1).fillna(0).clip(upper=0).cumsum().resample("30min")).ffill().fillna(0).diff(-1) * 2000 - df = df.fillna(0) + x = df.diff().clip(0).fillna(0).cumsum() + df.iloc[0] + x.index = x.index.round("1s") + y = -pd.concat([x.resample("1s").interpolate().resample("30min").asfreq(), x.iloc[-1:]]).diff(-1) + dt = y.index.diff().total_seconds() / pd.Timedelta("60min").total_seconds() / 1000 + df = y[1:-1] / dt[2:] + if start is not None: df = df.loc[start:] if end is not None: @@ -2004,58 +2288,93 @@ def load_consumption(self, start, end): self.log( f"Getting expected consumption data for {start.strftime(DATE_TIME_FORMAT_LONG)} to {end.strftime(DATE_TIME_FORMAT_LONG)}:" ) - - time_now = pd.Timestamp.now(tz="UTC") - if (start < time_now) and (end < time_now): - self.log(" - Start and end are both in past so actuals will be used with no weighting") - index = pd.date_range(start, end, inclusive="left", freq="30min") consumption = pd.DataFrame(index=index, data={"consumption": 0}) if self.get_config("use_consumption_history"): - entity_id = self.config["id_consumption_today"] - days = int(self.get_config("consumption_history_days")) + time_now = pd.Timestamp.now(tz="UTC") + if (start < time_now) and (end < time_now): + self.log(" - Start and end are both in past so actuals will be used with no weighting") + days = (time_now - start).days + 1 + else: + days = int(self.get_config("consumption_history_days")) - # if not isinstance(entity_ids, list): - # entity_ids = [entity_ids] + df = None - # for entity_id in entity_ids: - if (start < time_now) and (end < time_now): - consumption["consumption"] = self._get_hass_power_from_daily_kwh( - entity_id, - start=start, - end=end, - log=self.debug, - ) + entity_ids = [] + entity_id = None - else: + if "id_consumption" in self.config: + entity_ids = self.config["id_consumption"] + if not isinstance(entity_ids, list): + entity_ids = [entity_ids] + + entity_ids = [entity_id for entity_id in entity_ids if self.entity_exists(entity_id)] + + if ( + (len(entity_ids) == 0) + and ("id_consumption_today" in self.config) + and self.entity_exists(self.config["id_consumption_today"]) + ): + entity_id = self.config["id_consumption_today"] + + for entity_id in entity_ids: + power = self.hass2df(entity_id=entity_id, days=days) + + power = self.riemann_avg(power) + if df is None: + df = power + else: + df += power + + if df is None: + self.log("Getting consumpion") df = self._get_hass_power_from_daily_kwh( entity_id, days=days, log=self.debug, ) - if df is None: - self._status("ERROR: No consumption history.") - return + if df is None: + self._status("ERROR: No consumption history.") + return - actual_days = int( - round( - (df.index[-1] - df.index[0]).total_seconds() / 3600 / 24, - 0, - ) + actual_days = int( + round( + (df.index[-1] - df.index[0]).total_seconds() / 3600 / 24, + 0, ) + ) - self.log( - f" - Got {actual_days} days history from {entity_id} from {df.index[0].strftime(DATE_TIME_FORMAT_SHORT)} to {df.index[-1].strftime(DATE_TIME_FORMAT_SHORT)}" - ) - if int(actual_days) == days: - str_days = "OK" + self.log( + f" - Got {actual_days} days history from {entity_id} from {df.index[0].strftime(DATE_TIME_FORMAT_SHORT)} to {df.index[-1].strftime(DATE_TIME_FORMAT_SHORT)}" + ) + if int(actual_days) == days: + str_days = "OK" + else: + self._status(f"WARNING: Consumption < {days} days.") + str_days = "Potential error. <<<" + + self.log(f" - {days} days was expected. {str_days}") + + if (len(self.zappi_entities) > 0) and (self.get_config("ev_charger") == "Zappi"): + ev_power = self._get_zappi(start=df.index[0], end=df.index[-1], log=True) + if len(ev_power) > 0: + self.log("") + self.log(f" Deducting EV consumption of {ev_power.sum()/2000}") + self.log( + f">>> EV consumption from {ev_power.index[0].strftime(DATE_TIME_FORMAT_SHORT)} to {ev_power.index[-1].strftime(DATE_TIME_FORMAT_LONG)}" + ) + self.log( + f">>> House consumption from {df.index[0].strftime(DATE_TIME_FORMAT_SHORT)} to {df.index[-1].strftime(DATE_TIME_FORMAT_LONG)}" + ) else: - self._status(f"WARNING: Consumption < {days} days.") - str_days = "Potential error. <<<" + self.log("") + self.log(" No power returned from Zappi") - self.log(f" - {days} days was expected. {str_days}") + if (start < time_now) and (end < time_now): + consumption["consumption"] = df.loc[start:end] + else: df = df * (1 + self.get_config("consumption_margin") / 100) dfx = pd.Series(index=df.index, data=df.to_list()) # Group by time and take the mean @@ -2088,7 +2407,12 @@ def load_consumption(self, start, end): self.log(f" - Ignoring 'Day of Week Weighting' because only {days} days of history is available") consumption["consumption"] = consumption_mean + if len(entity_ids) > 0: + self.log(f" - Estimated consumption from {entity_ids} loaded OK ") + + else: self.log(f" - Estimated consumption from {entity_id} loaded OK ") + else: daily_kwh = self.get_config("daily_consumption_kwh") self.log(f" - Creating consumption based on daily estimate of {daily_kwh} kWh") @@ -2137,6 +2461,16 @@ def _compare_tariffs(self): contracts = [self.contract] + self.log("") + self.log(f"Start: {start.strftime(DATE_TIME_FORMAT_SHORT):>15s}") + self.log(f"End: {end.strftime(DATE_TIME_FORMAT_SHORT):>15s}") + self.log(f"Initial SOC: {initial_soc:>15.1f}%") + self.log(f"Consumption: {static['consumption'].sum()/2000:15.1f} kWh") + self.log(f"Solar: {static['solar'].sum()/2000:15.1f} kWh") + + if self.debug: + self.log(f">>> Yesterday's data:\n{static.to_string()}") + for tariff_set in self.config["alt_tariffs"]: code = {} tariffs = {} @@ -2155,7 +2489,7 @@ def _compare_tariffs(self): ) actual = self._cost_actual(start=start, end=end - pd.Timedelta(30, "minutes")) - + static["period_start"] = static.index.tz_convert(self.tz).strftime("%Y-%m-%dT%H:%M:%S%z").str[:-2] + ":00" entity_id = f"sensor.{self.prefix}_opt_cost_actual" self.set_state( state=round(actual.sum() / 100, 2), @@ -2165,7 +2499,8 @@ def _compare_tariffs(self): "device_class": "monetary", "unit_of_measurement": "GBP", "friendly_name": f"PV Opt Comparison Actual", - }, + } + | {col: static[["period_start", col]].to_dict("records") for col in ["solar", "consumption"]}, ) self.ulog("Net Cost comparison:", underline=None) @@ -2190,12 +2525,13 @@ def _compare_tariffs(self): static, contract, solar="solar", - discharge=self.get_config("forced_discharge"), + export=True, + discharge=True, max_iters=MAX_ITERS, - log=self.debug, + log=False, ) - opt["period_start"] = opt.index.tz_convert(self.tz).strftime("%Y-%m-%dT%H:%M:%S%z").str[:-2] + ":00" + # opt["period_start"] = opt.index.tz_convert(self.tz).strftime("%Y-%m-%dT%H:%M:%S%z").str[:-2] + ":00" attributes = { "state_class": "measurement", @@ -2216,23 +2552,33 @@ def _compare_tariffs(self): ) def _get_solar(self, start, end): - self.log("Getting yesterday's solar generation:") - entity_id = self.config["id_daily_solar"] + self.log( + f"Getting yesterday's solar generation ({start.strftime(DATE_TIME_FORMAT_SHORT)} - {end.strftime(DATE_TIME_FORMAT_SHORT)}):" + ) + # entity_id = self.config["id_daily_solar"] + entity_id = self.config["id_solar_power"] if entity_id is None or not self.entity_exists(entity_id): return - dt = pd.date_range( - start, - end, - freq="30min", - ) + # dt = pd.date_range( + # start, + # end, + # freq="30min", + # ) days = (pd.Timestamp.now(tz="UTC") - start).days + 1 - df = self.hass2df(entity_id, days=days).astype(float).resample("30min").ffill() + # df = self.hass2df(entity_id, days=days).astype(float).resample("30min").ffill() + + df = self.hass2df(entity_id, days=days) if df is not None: - df.index = pd.to_datetime(df.index) - self.log(f" - {df.loc[dt[-2]]:0.1f} kWh") - df = -df.loc[dt[0] : dt[-1]].diff(-1).clip(upper=0).iloc[:-1] * 2000 + + df = (self.riemann_avg(df).loc[start : end - pd.Timedelta("30min")] / 10).round(0) * 10 + + # df.index = pd.to_datetime(df.index) + # self.log(f" - {df.loc[dt[-2]]:0.1f} kWh") + # df = -df.loc[dt[0] : dt[-1]].diff(-1).clip(upper=0).iloc[:-1] * 2000 + # self.log(f"\n{df.to_string()}") + # self.log(f"\n{df2.to_string()}") else: self.log(" - FAILED") @@ -2258,7 +2604,9 @@ def _check_tariffs_vs_bottlecap(self): df = pd.concat( [ df, - self.contract.tariffs[direction].to_df(start=df.index[0], end=df.index[-1])["unit"], + self.contract.tariffs[direction].to_df(start=df.index[0], end=df.index[-1], day_ahead=False)[ + "unit" + ], ], axis=1, ).set_axis(["bottlecap", "pv_opt"], axis=1) @@ -2283,9 +2631,8 @@ def _check_tariffs_vs_bottlecap(self): err = True self.log(f" {direction.title()}: {str_log}") - if err: - self.rlog(self.contract.tariffs[direction]) + self.rlog(self.contract.tariffs[direction].to_df(start=df.index[0], end=df.index[-1], day_ahead=False)) def ulog(self, strlog, underline="-", words=False): self.log("") @@ -2317,10 +2664,10 @@ def _list_entities(self, domains=["select", "number", "sensor"]): x = " - " for attribute in DOMAIN_ATTRIBUTES[domain]: x = f"{x} {attribute}: {states[entity_id]['attributes'][attribute]} " - self.log(x) + self.rlog(x) elif domain == "select": for option in states[entity_id]["attributes"]["options"]: - self.log(f"{option:>83s}") + self.rlog(f"{option:>83s}") self.log("") def hass2df(self, entity_id, days=2, log=False, freq=None): @@ -2369,9 +2716,13 @@ def write_and_poll_value(self, entity_id, value, tolerance=0.0, verbose=False): try: self.call_service("number/set_value", entity_id=entity_id, value=str(value)) - time.sleep(WRITE_POLL_SLEEP) - new_state = float(self.get_state_retry(entity_id=entity_id)) - written = new_state == value + written = False + retries = 0 + while not written and retries < WRITE_POLL_RETRIES: + retries += 1 + time.sleep(WRITE_POLL_SLEEP) + new_state = float(self.get_state_retry(entity_id=entity_id)) + written = new_state == value except: written = False @@ -2408,7 +2759,7 @@ def get_state_retry(self, *args, **kwargs): retries += 1 self.rlog( f" - Retrieved invalid state of {state} for {kwargs.get('entity_id', None)} (Attempt {retries} of {GET_STATE_RETRIES})", - level="WARN", + level="WARNING", ) time.sleep(GET_STATE_WAIT) @@ -2418,5 +2769,13 @@ def get_state_retry(self, *args, **kwargs): else: return state + def riemann_avg(self, x, freq="30min"): + dt = x.index.diff().total_seconds().fillna(0) + + integral = (dt * x.shift(1)).fillna(0).cumsum().resample(freq).last() + avg = (integral.diff().shift(-1)[:-1] / pd.Timedelta(freq).total_seconds()).fillna(0).round(1) + # self.log(avg) + return avg + # %% diff --git a/apps/pv_opt/pvpy.py b/apps/pv_opt/pvpy.py index fe91463..79606bd 100644 --- a/apps/pv_opt/pvpy.py +++ b/apps/pv_opt/pvpy.py @@ -7,7 +7,8 @@ from datetime import datetime OCTOPUS_PRODUCT_URL = r"https://api.octopus.energy/v1/products/" -TIME_FORMAT = "%d/%m %H:%M" +TIME_FORMAT = "%d/%m %H:%M %Z" +MAX_ITERS = 3 AGILE_FACTORS = { "import": { @@ -65,8 +66,10 @@ def __init__( self.host = host if host is None: self.log = print + self.tz = "GB" else: self.log = host.log + self.tz = host.tz self.export = export self.eco7 = eco7 @@ -200,7 +203,11 @@ def to_df(self, start=None, end=None, **kwargs): self.log("") self.log(f"Cleared day ahead forecast for tariff {self.name}") - if pd.Timestamp.now(tz="UTC").hour > 11 and df.index[-1].day != end.day: + if pd.Timestamp.now(tz=self.tz).hour > 11 and df.index[-1].day != end.day: + # self.log(f">>> {pd.Timestamp.now(tz=self.tz).hour}") + # self.log(f">>> {df.index[-1].day}") + # self.log(f">>> {end.day}") + # if it is after 11 but we don't have new Agile prices yet, check for a day-ahead forecast if self.day_ahead is None: self.day_ahead = self.get_day_ahead(df.index[0]) @@ -306,7 +313,7 @@ def get_day_ahead(self, start): else: if len(i["Name"]) > 8: try: - self.log(time, i["Name"], i["Value"]) + # self.log(time, i["Name"], i["Value"]) data.append(float(i["Value"].replace(",", "."))) index.append( pd.Timestamp( @@ -398,9 +405,11 @@ def __init__( if self.host: self.log = host.log self.rlog = host.rlog + self.tz = host.tz else: self.log = print self.rlog = print + self.tz = "GB" if imp is None and octopus_account is None: raise ValueError("Either a named import tariff or Octopus Account details much be provided") @@ -497,8 +506,10 @@ def __init__(self, name: str, inverter: InverterModel, battery: BatteryModel, ho self.host = host if host: self.log = host.log + self.tz = host.tz else: self.log = print + self.tz = "GB" def __str__(self): pass @@ -581,7 +592,8 @@ def optimised_force(self, initial_soc, static_flows, contract: Contract, **kwarg consumption.name = "consumption" discharge = kwargs.pop("discharge", False) - max_iters = kwargs.pop("max_iters", 3) + use_export = kwargs.pop("export", True) + max_iters = kwargs.pop("max_iters", MAX_ITERS) prices = pd.DataFrame() for direction in contract.tariffs: @@ -602,6 +614,10 @@ def optimised_force(self, initial_soc, static_flows, contract: Contract, **kwarg ) prices = prices.set_axis([t for t in contract.tariffs.keys() if contract.tariffs[t] is not None], axis=1) + if not use_export: + self.log(f"Ignoring export pricing because Use Export is turned off") + discharge = False + prices["export"] = 0 df = pd.concat( [prices, consumption, self.flows(initial_soc, static_flows, **kwargs)], @@ -667,11 +683,12 @@ def optimised_force(self, initial_soc, static_flows, contract: Contract, **kwarg done = True import_cost = ((df["import"] * df["grid"]).clip(0) / 2000)[available] + if len(import_cost[df["forced"] == 0]) > 0: max_import_cost = import_cost[df["forced"] == 0].max() max_slot = import_cost[import_cost == max_import_cost].index[0] - max_slot_energy = df["grid"].loc[max_slot] / 2000 # kWh + max_slot_energy = round(df["grid"].loc[max_slot] / 2000, 2) # kWh if max_slot_energy > 0: round_trip_energy_required = ( @@ -692,7 +709,7 @@ def optimised_force(self, initial_soc, static_flows, contract: Contract, **kwarg x = x[x["soc_end"] <= 97] search_window = x.index - str_log = f"{max_slot.strftime(TIME_FORMAT)}: {round_trip_energy_required:5.2f} kWh at {max_import_cost:6.2f}p. " + str_log = f"{max_slot.tz_convert(self.tz).strftime(TIME_FORMAT)}: {round_trip_energy_required:5.2f} kWh at {max_import_cost:6.2f}p. " if len(search_window) > 0: # str_log += f"Window: [{search_window[0].strftime(TIME_FORMAT)}-{search_window[-1].strftime(TIME_FORMAT)}] " pass @@ -707,7 +724,7 @@ def optimised_force(self, initial_soc, static_flows, contract: Contract, **kwarg cost_at_min_price = round_trip_energy_required * min_price - str_log += f"<==> {start_window.strftime(TIME_FORMAT)}: {min_price:5.2f}p/kWh {cost_at_min_price:5.2f}p " + str_log += f"<==> {start_window.tz_convert(self.tz).strftime(TIME_FORMAT)}: {min_price:5.2f}p/kWh {cost_at_min_price:5.2f}p " str_log += f" SOC: {x.loc[window[0]]['soc']:5.1f}%->{x.loc[window[-1]]['soc_end']:5.1f}% " factors = [] for slot in window: @@ -731,7 +748,7 @@ def optimised_force(self, initial_soc, static_flows, contract: Contract, **kwarg round( min( round_trip_energy_required * 2000 * factor, - self.inverter.charger_power - x["forced"].loc[slot], + max(self.inverter.charger_power - x["forced"].loc[slot], 0), ((100 - x["soc_end"].loc[slot]) / 100 * self.battery.capacity) * 2 * factor, @@ -754,14 +771,15 @@ def optimised_force(self, initial_soc, static_flows, contract: Contract, **kwarg str_log += f"New SOC: {df.loc[start_window]['soc']:5.1f}%->{df.loc[start_window]['soc_end']:5.1f}% " net_cost_opt = net_cost[-1] str_log += f"Net: {net_cost_opt:6.1f}" - if self.host.debug: + if log: self.log(str_log) - xx = pd.concat( - [old_cost, old_soc, contract.net_cost(df), df["soc_end"], df["import"]], axis=1 - ).set_axis(["Old", "Old_SOC", "New", "New_SOC", "import"], axis=1) - xx["Diff"] = xx["New"] - xx["Old"] - self.log(f"\n{xx.loc[window[0] : max_slot].to_string()}") - # yy = False + if self.host.debug: + xx = pd.concat( + [old_cost, old_soc, contract.net_cost(df), df["soc_end"], df["import"]], axis=1 + ).set_axis(["Old", "Old_SOC", "New", "New_SOC", "import"], axis=1) + xx["Diff"] = xx["New"] - xx["Old"] + self.log(f"\n{xx.loc[window[0] : max_slot].to_string()}") + # yy = False else: available[max_slot] = False else: @@ -781,9 +799,10 @@ def optimised_force(self, initial_soc, static_flows, contract: Contract, **kwarg net_cost_opt = round(contract.net_cost(df).sum(), 1) if base_cost - net_cost_opt <= self.host.get_config("pass_threshold_p"): - self.log( - f"Charge net cost delta: {base_cost - net_cost_opt:0.1f}p: < Pass Threshold ({self.host.get_config('pass_threshold_p'):0.1f}p) => Slots Excluded" - ) + if log: + self.log( + f"Charge net cost delta: {base_cost - net_cost_opt:0.1f}p: < Pass Threshold ({self.host.get_config('pass_threshold_p'):0.1f}p) => Slots Excluded" + ) slots = [] net_cost_opt = base_cost df = pd.concat( diff --git a/apps/pv_opt/solax.py b/apps/pv_opt/solax.py new file mode 100644 index 0000000..a3d7766 --- /dev/null +++ b/apps/pv_opt/solax.py @@ -0,0 +1,233 @@ +import pandas as pd +import time + +TIMEFORMAT = "%H:%M" +LIMITS = ["start", "end"] +DIRECTIONS = ["charge"] +WRITE_POLL_SLEEP_DURATION = 0.5 + +INVERTER_DEFS = { + "SOLAX_X1": { + "MODE_ITEMS": [ + "use_mode", + "allow_grid_charge", + "lock_state", + "backup_grid_charge", + ], + "PERIODS": {"charge": 2, "discharge": 0}, + # Default Configuration: Exposed as inverter.config and provides defaults for this inverter for the + # required config. These config items can be over-written by config specified in the config.yaml + # file. They are required for the main PV_Opt module and if they cannot be found an ERROR will be + # raised + "online": "number.{device_name}_battery_minimum_capacity", + "default_config": { + "maximum_dod_percent": "number.{device_name}_battery_minimum_capacity", + "id_battery_soc": " sensor.{device_name}_battery_capacity", + "id_consumption": "sensor.{device_name}_house_load", + "id_grid_import_today": "sensor.{device_name}_today_s_import_energy", + "id_grid_export_today": "sensor.{device_name}_today_s_export_energy", + "supports_hold_soc": False, + "supports_forced_discharge": False, + "update_cycle_seconds": 15, + }, + # Brand Conguration: Exposed as inverter.brand_config and can be over-written using arguments + # from the config.yaml file but not rquired outside of this module + "brand_config": { + "battery_voltage": "sensor.{device_name}_battery_voltage_charge", + "id_allow_grid_charge": "select.{device_name}_allow_grid_charge", + "id_battery_capacity": "sensor.{device_name}_battery_capacity", + "id_battery_minimum_capacity": "number.{device_name}_battery_minimum_capacity", + "id_battery_charge_max_current": "number.{device_name}_battery_charge_max_current", + "id_battery_discharge_max_current": "number.{device_name}_battery_discharge_max_current", + "id_charge_end_time_1": "select.{device_name}_charger_end_time_1", + "id_charge_start_time_1": "select.{device_name}_charger_start_time_1", + "id_charge_end_time_2": "select.{device_name}_charger_end_time_2", + "id_charge_start_time_2": "select.{device_name}_charger_start_time_2", + "id_max_charge_current": "number.{device_name}_battery_charge_max_current", + "id_use_mode": "select.{device_name}_charger_use_mode", + "id_lock_state": "select.{device_name}_lock_state", + "id_export_duration": "select.{device_name}_export_duration", + "id_target_soc": "number.{device_name}_forcetime_period_1_max_capacity", + "id_backup_grid_charge": "select.{device_name}_backup_grid_charge", + }, + }, +} + + +class InverterController: + def __init__(self, inverter_type, host) -> None: + self.host = host + self.tz = self.host.tz + if host is not None: + self.log = host.log + self.type = inverter_type + self.config = {} + self.brand_config = {} + for defs, conf in zip( + [INVERTER_DEFS[self.type][x] for x in ["default_config", "brand_config"]], + [self.config, self.brand_config], + ): + 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("{inverter_sn}", self.host.inverter_sn) + elif isinstance(defs[item], list): + conf[item] = [z.replace("{device_name}", self.host.device_name) for z in defs[item]] + # conf[item] = [z.replace("{inverter_sn}", self.host.inverter_sn) for z in defs[item]] + else: + conf[item] = defs[item] + + def is_online(self): + entity_id = INVERTER_DEFS[self.type].get("online", (None, None)) + if entity_id is not None: + entity_id = entity_id.replace("{device_name}", self.host.device_name) + return self.host.get_state(entity_id) not in ["unknown", "unavailable"] + else: + return True + + def enable_timed_mode(self): + if self.type == "SOLAX_X1": + self.host.set_select("lock_state", "Unlocked - Advanced") + self.host.set_select("allow_grid_charge", "Period 1 Allowed") + self.host.set_select("backup_grid_charge", "Disabled") + + else: + self._unknown_inverter() + + def control_charge(self, enable, **kwargs): + if self.type == "SOLAX_X1": + if enable: + self.host.set_select("use_mode", "Force Time Use") + time_now = pd.Timestamp.now(tz=self.tz) + start = kwargs.get("start", time_now).floor("15min").strftime(TIMEFORMAT) + end = kwargs.get("end", time_now).ceil("30min").strftime(TIMEFORMAT) + self.host.set_select("charge_start_time_1", start) + self.host.set_select("charge_end_time_1", end) + self.host.set_select("charge_start_time_2", start) + self.host.set_select("charge_end_time_2", end) + + power = kwargs.get("power") + if power is not None: + entity_id = self.host.config[f"id_max_charge_current"] + current = abs(round(power / self.host.get_config("battery_voltage"), 1)) + current = min(current, self.host.get_config("battery_current_limit_amps")) + + self.log(f"Power {power:0.0f} = {current:0.1f}A at {self.host.get_config('battery_voltage')}V") + changed, written = self.host.write_and_poll_value( + entity_id=entity_id, value=current, tolerance=1, verbose=True + ) + + if changed: + if written: + self.log(f"Current {current}A written to inverter") + else: + self.log(f"Failed to write current of {current}A to inverter") + else: + self.log("Inverter already at correct current") + + target_soc = kwargs.get("target_soc", None) + if target_soc is not None: + entity_id = self.host.config[f"id_target_soc"] + + changed, written = self.host.write_and_poll_value( + entity_id=entity_id, value=target_soc, tolerance=1, verbose=True + ) + + if changed: + if written: + self.log(f"Target SOC {target_soc}% written to inverter") + else: + self.log(f"Failed to write Target SOC of {target_soc}% to inverter") + else: + self.log("Inverter already at correct Target SOC") + else: + self.host.set_select("use_mode", "Self Use Mode") + time_now = pd.Timestamp.now(tz=self.tz) + start = kwargs.get("start", time_now).normalize().strftime(TIMEFORMAT) + end = start + self.host.set_select("charge_start_time_1", start) + self.host.set_select("charge_end_time_1", end) + self.host.set_select("charge_start_time_2", start) + self.host.set_select("charge_end_time_2", end) + + else: + self._unknown_inverter() + + def control_discharge(self, enable, **kwargs): + if self.type == "SOLAX_X1": + pass + else: + self._unknown_inverter() + + def _unknown_inverter(self): + e = f"Unknown inverter type {self.type}" + self.log(e, level="ERROR") + self.host.status(e) + raise Exception(e) + + def hold_soc(self, enable, soc=None): + if self.type == "SOLAX_X1": + pass + else: + self._unknown_inverter() + + @property + def status(self): + status = None + if self.type == "SOLAX_X1": + time_now = pd.Timestamp.now(tz=self.tz) + midnight = time_now.normalize() + + status = self._solax_mode() + + status["charge"] = self._solax_charge_periods() + try: + status["charge"]["active"] = ( + time_now >= status["charge"]["start"] + and time_now < status["charge"]["end"] + and status["charge"]["current"] > 0 + and status["use_mode"]["Timed"] == "Force Time Use" + ) + except: + status["charge"]["active"] = False + + status["discharge"] = { + "start": midnight, + "end": midnight, + "current": 0.0, + "active": False, + } + status["hold_soc"] = { + "active": False, + "soc": 0.0, + } + + else: + self._unknown_inverter() + + return status + + def _monitor_target_soc(self, target_soc, mode="charge"): + pass + + def _solax_mode(self, **kwargs): + if self.type == "SOLAX_X1": + return { + x: self.host.get_state_retry(entity_id=self.host.config[f"id_{x}"]) + for x in INVERTER_DEFS[self.type]["MODE_ITEMS"] + } + + else: + self._unknown_inverter() + + def _solax_charge_periods(self, **kwargs): + if self.type == "SOLAX_X1": + return { + limit: pd.Timestamp( + self.host.get_state_retry(entity_id=self.host.config[f"id_charge_{limit}_time_1"]), tz=self.tz + ) + for limit in LIMITS + } | {"current": self.host.get_state_retry(entity_id=self.host.config[f"id_max_charge_current"])} + + else: + self._unknown_inverter() diff --git a/apps/pv_opt/solis.py b/apps/pv_opt/solis.py index e97db44..36f68a0 100644 --- a/apps/pv_opt/solis.py +++ b/apps/pv_opt/solis.py @@ -52,10 +52,10 @@ "maximum_dod_percent": "number.{device_name}_battery_minimum_soc", "id_battery_soc": "sensor.{device_name}_battery_soc", "id_consumption_today": "sensor.{device_name}_house_load_today", - # "id_consumption": [ - # "sensor.{device_name}_house_load", - # "sensor.{device_name}_bypass_load", - # ], + "id_consumption": [ + "sensor.{device_name}_house_load_x", + "sensor.{device_name}_bypass_load_x", + ], "id_grid_import_today": "sensor.{device_name}_grid_import_today", "id_grid_export_today": "sensor.{device_name}_grid_export_today", # "id_grid_import_power": "sensor.{device_name}_grid_import_power", @@ -63,6 +63,7 @@ "id_battery_charge_power": "sensor.{device_name}_battery_input_energy", "id_inverter_ac_power": "sensor.{device_name}_active_power", "supports_hold_soc": True, + "supports_forced_discharge": True, "update_cycle_seconds": 15, }, # Brand Conguration: Exposed as inverter.brand_config and can be over-written using arguments @@ -120,6 +121,7 @@ "id_grid_power": "sensor.{device_name}_grid_active_power", "id_inverter_ac_power": "sensor.{device_name}_inverter_ac_power", "supports_hold_soc": True, + "supports_forced_discharge": True, "update_cycle_seconds": 60, }, "brand_config": { @@ -183,6 +185,7 @@ "id_grid_power": "sensor.{device_name}_meter_active_power", "id_inverter_ac_power": "sensor.{device_name}_inverter_ac_power", "supports_hold_soc": True, + "supports_forced_discharge": True, "update_cycle_seconds": 60, }, "brand_config": { @@ -236,6 +239,8 @@ def is_online(self): 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) + else: + self._unknown_inverter() def control_charge(self, enable, **kwargs): if enable: @@ -247,14 +252,35 @@ def control_discharge(self, enable, **kwargs): self.enable_timed_mode() self._control_charge_discharge("discharge", enable, **kwargs) - def hold_soc(self, enable, soc=None): + def hold_soc(self, enable, soc=None, **kwargs): + if self.type == "SOLIS_SOLAX_MODBUS" or self.type == "SOLIS_CORE_MODBUS" or self.type == "SOLIS_SOLARMAN": + start = kwargs.get("start", pd.Timestamp.now(tz=self.tz).floor("1min")) + end = kwargs.get("end", pd.Timestamp.now(tz=self.tz).ceil("30min")) + self._solis_control_charge_discharge( + "charge", + enable=enable, + start=start, + end=end, + power=0, + ) + else: + self._unknown_inverter() + + def _unknown_inverter(self): + e = f"Unknown inverter type {self.type}" + self.log(e, level="ERROR") + self.host.status(e) + raise Exception(e) + + def hold_soc_old(self, enable, soc=None): 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) else: self.enable_timed_mode() - # Waiyt for a second to make sure the mode is correct + # Wait for a second to make sure the mode is correct time.sleep(1) if soc is None: @@ -271,10 +297,9 @@ def hold_soc(self, enable, soc=None): value=soc, entity_id=entity_id, ) - else: - e = "Unknown inverter type" - self.log(e, level="ERROR") - raise Exception(e) + + else: + self._unknown_inverter() @property def status(self): @@ -388,6 +413,7 @@ 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)) + current = min(current, self.host.get_config("battery_current_limit_amps")) 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.host.write_and_poll_value(entity_id=entity_id, value=current, tolerance=1) diff --git a/apps/pv_opt/sunsynk.py b/apps/pv_opt/sunsynk.py new file mode 100644 index 0000000..f992bed --- /dev/null +++ b/apps/pv_opt/sunsynk.py @@ -0,0 +1,265 @@ +import pandas as pd +import time +import json + +TIMEFORMAT = "%H:%M" +INVERTER_DEFS = { + "SUNSYNK_SOLARSYNK2": { + "online": "sensor.{device_name}_{inverter_sn}_battery_soc", + # "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", + # }, + # "bits": [ + # "SelfUse", + # "Timed", + # "OffGrid", + # "BatteryWake", + # "Backup", + # "GridCharge", + # "FeedInPriority", + # ], + # Default Configuration: Exposed as inverter.config and provides defaults for this inverter for the + # required config. These config items can be over-written by config specified in the config.yaml + # file. They are required for the main PV_Opt module and if they cannot be found an ERROR will be + # raised + "default_config": { + "maximum_dod_percent": 20, + "id_battery_soc": "sensor.{device_name}_{inverter_sn}_battery_soc", + "id_consumption_today": "sensor.{device_name}_{inverter_sn}_day_load_energy", + "id_consumption": "sensor.{device_name}_{inverter_sn}_load_power", + "id_grid_import_today": "sensor.{device_name}_{inverter_sn}_day_grid_import", + "id_grid_export_today": "sensor.{device_name}_{inverter_sn}_day_grid_export", + "supports_hold_soc": False, + "update_cycle_seconds": 300, + }, + # Brand Conguration: Exposed as inverter.brand_config and can be over-written using arguments + # from the config.yaml file but not required outside of this module + "brand_config": { + "battery_voltage": "sensor.{device_name}_{inverter_sn}_battery_voltage", + "battery_current": "sensor.{device_name}_{inverter_sn}_battery_current", + "id_control_helper": "input_text.{device_name}_{inverter_sn}_settings", + "id_use_timer": "sensor.{device_name}_{inverter_sn}_use_timer", + "id_priority_load": "sensor.{device_name}_{inverter_sn}_priority_load", + "id_timed_charge_start": "sensor.{device_name}_{inverter_sn}_prog1_time", + "id_timed_charge_end": "sensor.{device_name}_{inverter_sn}_prog2_time", + "id_timed_charge_unused": ["sensor.{device_name}_{inverter_sn}_" + f"prog{i}_time" for i in range(2, 7)], + "id_timed_charge_enable": "sensor.{device_name}_{inverter_sn}_prog1_charge", + "id_timed_charge_capacity": "sensor.{device_name}_{inverter_sn}_prog1_capacity", + "id_timed_discharge_start": "sensor.{device_name}_{inverter_sn}_prog3_time", + "id_timed_discharge_end": "sensor.{device_name}_{inverter_sn}_prog4_time", + "id_timed_discharge_unused": [ + "sensor.{device_name}_{inverter_sn}_" + f"prog{i}_time" for i in [1, 2, 5, 6] + ], + "id_timed_dicharge_enable": "sensor.{device_name}_{inverter_sn}_prog3_charge", + "id_timed_discharge_capacity": "sensor.{device_name}_{inverter_sn}_prog3_capacity", + "json_work_mode": "sysWorkMode", + "json_priority_load": "energyMode", + "json_grid_charge": "sdChargeOn", + "json_use_timer": "peakAndVallery", + "json_timed_charge_start": "sellTime1", + "json_timed_charge_end": "sellTime2", + "json_timed_charge_unused": [f"sellTime{i}" for i in range(2, 7)], + "json_timed_charge_enable": "time1on", + "json_timed_charge_target_soc": "cap1", + "json_charge_current": "sdBatteryCurrent", + "json_gen_charge_enable": "genTime1on", + "json_timed_discharge_start": "sellTime3", + "json_timed_discharge_end": "sellTime4", + "json_timed_discharge_unused": [f"sellTime{i}" for i in [1, 2, 5, 6]], + "json_timed_discharge_enable": "time3on", + "json_timed_discharge_target_soc": "cap3", + "json_timed_discharge_power": "sellTime3Pac", + "json_gen_discharge_enable": "genTime3on", + }, + }, +} + + +class InverterController: + def __init__(self, inverter_type, host) -> None: + self.host = host + self.tz = self.host.tz + if host is not None: + self.log = host.log + self.tz = self.host.tz + self.config = self.host.config + + self.type = inverter_type + + self.brand_config = {} + for defs, conf in zip( + [INVERTER_DEFS[self.type][x] for x in ["default_config", "brand_config"]], + [self.config, self.brand_config], + ): + 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("{inverter_sn}", self.host.inverter_sn) + elif isinstance(defs[item], list): + conf[item] = [z.replace("{device_name}", self.host.device_name) for z in defs[item]] + conf[item] = [z.replace("{inverter_sn}", self.host.inverter_sn) for z in defs[item]] + else: + conf[item] = defs[item] + + def is_online(self): + entity_id = INVERTER_DEFS[self.type].get("online", (None, None)) + if entity_id is not None: + entity_id = entity_id.replace("{device_name}", self.host.device_name) + return self.host.get_state(entity_id) not in ["unknown", "unavailable"] + else: + return True + + def _unknown_inverter(self): + e = f"Unknown inverter type {self.type}" + self.log(e, level="ERROR") + self.host.status(e) + raise Exception(e) + + def _solarsynk_set_helper(self, **kwargs): + current_json = json.loads(self.host.get_config("id_control_helper")) + new_json = json.dumps(current_json | kwargs) + entity_id = self.host.config("id_control_helper") + self.rlog("Setting SolarSync input helper {entity_id} to {new_json}") + # self.host.set_state(entity_id=entity_id, state=new_json) + + def enable_timed_mode(self): + if self.type == "SUNSYNK_SOLARSYNK2": + params = { + self.config["json_use_timer"]: 1, + self.config["json_priority_load"]: 1, + } + self._solarsynk_set_helper(params) + + else: + self._unknown_inverter() + + def control_charge(self, enable, **kwargs): + if self.type == "SUNSYNK_SOLARSYNK2": + time_now = pd.Timestamp.now(tz=self.tz) + + if enable: + self.enable_timed_mode() + params = { + self.config["json_work_mode"]: 2, + self.config["json_timed_charge_target_soc"]: kwargs.get("target_soc", 100), + self.config["json_timed_charge_start"]: kwargs.get("start", time_now.strftime(TIMEFORMAT)), + self.config["json_timed_charge_end"]: kwargs.get( + "end", time_now.ceil("30min").strftime(TIMEFORMAT) + ), + self.config["json_charge_current"]: min( + kwargs.get("power", 0) / self.host.get_config("battery_voltage"), + self.host.get_config("battery_current_limit_amps"), + ), + self.config["json_timed_charge_enable"]: True, + self.config["json_gen_charge_enable"]: False, + } | {x: "00:00" for x in self.config["json_timed_charge_unused"]} + + self._solarsynk_set_helper(params) + + else: + params = { + self.config["json_work_mode"]: 2, + self.config["json_target_soc"]: 100, + self.config["json_timed_charge_start"]: "00:00", + self.config["json_timed_charge_end"]: "00:00", + self.config["json_charge_current"]: self.host.get_config("battery_current_limit_amps"), + self.config["json_timed_charge_enable"]: False, + self.config["json_gen_charge_enable"]: True, + } | {x: "00:00" for x in self.config["json_timed_charge_unused"]} + else: + self._unknown_inverter() + + def control_discharge(self, enable, **kwargs): + if self.type == "SUNSYNK_SOLARSYNK2": + time_now = pd.Timestamp.now(tz=self.tz) + + if enable: + self.enable_timed_mode() + params = { + self.config["json_work_mode"]: 0, + self.config["json_timed_discharge_target_soc"]: kwargs.get( + "target_soc", self.host.get_config("maximum_dod_percent") + ), + self.config["json_timed_discharge_start"]: kwargs.get("start", time_now.strftime(TIMEFORMAT)), + self.config["json_timed_discharge_end"]: kwargs.get( + "end", time_now.ceil("30min").strftime(TIMEFORMAT) + ), + self.config["json_discharge_power"]: kwargs.get("power", 0), + self.config["json_timed_discharge_enable"]: True, + self.config["json_gen_discharge_enable"]: False, + } | {x: "00:00" for x in self.config["json_timed_discharge_unused"]} + + self._solarsynk_set_helper(params) + + else: + params = { + self.config["json_work_mode"]: 2, + self.config["json_timed_discharge_target_soc"]: 100, + self.config["json_timed_discharge_start"]: "00:00", + self.config["json_timed_discharge_end"]: "00:00", + self.config["json_discharge_power"]: 0, + self.config["json_timed_discharge_enable"]: False, + self.config["json_gen_discharge_enable"]: True, + } | {x: "00:00" for x in self.config["json_timed_discharge_unused"]} + else: + self._unknown_inverter() + + def hold_soc(self, enable, soc=None): + if self.type == "SUNSYNK_SOLARSYNK2": + pass + + else: + self._unknown_inverter() + + @property + def status(self): + status = None + time_now = pd.Timestamp.now(tz=self.tz) + + if self.type == "SUNSYNK_SOLARSYNK2": + charge_start = pd.Timestamp(self.host.get_config("id_timed_charge_start"), tz=self.tz) + charge_end = pd.Timestamp(self.host.get_config("id_timed_charge_end"), tz=self.tz) + discharge_start = pd.Timestamp(self.host.get_config("id_timed_charge_start"), tz=self.tz) + discharge_end = pd.Timestamp(self.host.get_config("id_timed_charge_end"), tz=self.tz) + + status = { + "timer mode": self.host.get_config("id_use_timer"), + "priority load": self.host.get_config("id_priority_load"), + "charge": { + "start": charge_start, + "end": charge_end, + "active": self.host.get_config("id_timed_charge_enable") + and (time_now >= charge_start) + and (time_now < charge_end), + "target_soc": self.host.get_config("id_timed_charge_target_soc"), + }, + "discharge": { + "start": discharge_start, + "end": discharge_end, + "active": self.host.get_config("id_timed_discharge_enable") + and (time_now >= discharge_start) + and (time_now < discharge_end), + "target_soc": self.host.get_config("id_timed_discharge_target_soc"), + }, + "hold_soc": { + "active": False, + "soc": 0.0, + }, + } + + return status + + else: + self._unknown_inverter() + + def _monitor_target_soc(self, target_soc, mode="charge"): + pass diff --git a/pvopt_control_card.yaml b/pvopt_control_card.yaml new file mode 100644 index 0000000..a40262c --- /dev/null +++ b/pvopt_control_card.yaml @@ -0,0 +1,134 @@ +type: custom:stack-in-card +title: Optimised Charging +cards: + - type: entities + entities: + - entity: sensor.pvopt_status + name: Status + - type: markdown + content:

Control Parameters + - type: entities + entities: + - entity: number.pvopt_optimise_frequency_minutes + name: Optimiser Freq (mins) + - entity: switch.pvopt_read_only + name: Read Only Mode + - entity: switch.pvopt_include_export + name: Include Export + - type: conditional + conditions: + - condition: state + entity: switch.pvopt_include_export + state: 'on' + card: + type: entities + entities: + - entity: switch.pvopt_forced_discharge + name: Optimise Discharging + - type: conditional + conditions: + - condition: state + entity: switch.pvopt_forced_discharge + state: 'on' + - condition: state + entity: switch.pvopt_include_export + state: 'on' + card: + type: entities + entities: + - entity: switch.pvopt_allow_cyclic + name: Allow Cyclic Charge/Discharge + - type: markdown + content:

Solar + - type: entities + entities: + - entity: switch.pvopt_use_solar + name: Use Solar + - type: conditional + conditions: + - condition: state + entity: switch.pvopt_use_solar + state: 'on' + card: + type: entities + entities: + - entity: number.pvopt_solcast_confidence_level + name: Solcast Confidence Level + - type: markdown + content:

Consumption and EV + - type: entities + entities: + - entity: switch.pvopt_use_consumption_history + name: Use Consumption History + - type: conditional + conditions: + - condition: state + entity: switch.pvopt_use_consumption_history + state: 'on' + card: + type: entities + entities: + - entity: number.pvopt_consumption_history_days + name: Load History Days + - entity: number.pvopt_consumption_margin + name: Load Margin + - entity: number.pvopt_day_of_week_weighting + name: Weekday Weighting + - type: conditional + conditions: + - condition: state + entity: switch.pvopt_use_consumption_history + state: 'off' + card: + type: entities + entities: + - entity: number.pvopt_daily_consumption_kwh + name: Daily Consumption (kWh) + - entity: switch.pvopt_shape_consumption_profile + name: Shape Consumption Profile + - type: entities + entities: + - entity: select.pvopt_ev_charger + name: EV Charger + - type: conditional + conditions: + - condition: state + entity: select.pvopt_ev_charger + state_not: None + card: + type: entities + entities: + - entity: number.pvopt_ev_charger_power_watts + name: EV Charger Power + - entity: number.pvopt_ev_battery_capacity_kwh + name: EV Battery Capacity + - type: markdown + content:

System Parameters + - type: entities + entities: + - entity: number.pvopt_battery_capacity_wh + name: Battery Capacity + - entity: number.pvopt_inverter_power_watts + name: Inverter Power + - entity: number.pvopt_charger_power_watts + name: Charger Power + - entity: number.pvopt_inverter_efficiency_percent + name: Inverter Efficiency + - entity: number.pvopt_charger_efficiency_percent + name: Charger Efficiency + - entity: number.pvopt_battery_current_limit_amps + name: Battery Current Limit + - type: markdown + content: Tuning Parameters + - type: entities + entities: + - entity: number.pvopt_pass_threshold_p + name: Charge threshold (p) + - entity: number.pvopt_discharge_threshold_p + name: Discharge threshold (p) + - entity: number.pvopt_plunge_threshold_p_kwh + name: Plunge threshold (p/kWh) + - entity: number.pvopt_slot_threshold_p + name: Threshold per slot (p) + - entity: number.pvopt_forced_power_group_tolerance + name: Power Resolution diff --git a/pvopt_dashboard.yaml b/pvopt_dashboard.yaml index 5e167d2..4e2e1bf 100644 --- a/pvopt_dashboard.yaml +++ b/pvopt_dashboard.yaml @@ -21,7 +21,7 @@ views: name: Solar max: 5000 icon: mdi:solar-power-variant - - entity: sensor.solis_house_load + - entity: sensor.solis_total_load name: Load max: 5000 - entity: sensor.solis_battery_soc @@ -64,21 +64,21 @@ views: |:--|---:|---:|---:| - |Base | {{'%0.2f' | + |Base | {{'%8.2f' | format(state_attr('sensor.pvopt_base_cost','cost_today')| float)}} | {{'%0.2f' | format(state_attr('sensor.pvopt_base_cost','cost_tomorrow')| float)}} | {{'%0.2f' | format(states('sensor.pvopt_base_cost')| float)}} | - |Optimised | {{'%0.2f' | + |Optimised | {{'%8.2f' | format(state_attr('sensor.pvopt_opt_cost','cost_today')| float)}} | {{'%0.2f' | format(state_attr('sensor.pvopt_opt_cost','cost_tomorrow')| float)}} | {{'%0.2f' | format(states('sensor.pvopt_opt_cost')| float)}} | - |Cost Saving | {{'%0.2f' | + |Cost Saving | {{'%8.2f' | format((state_attr('sensor.pvopt_base_cost','cost_today')| float-state_attr('sensor.pvopt_opt_cost','cost_today')| float) | round(2))}} |{{'%0.2f' | @@ -89,6 +89,21 @@ views: states('sensor.pvopt_opt_cost')| float) | round(2)) }} | +

Optimisation Breakdown (GBP)

+ + + | | Cost || + + |:--|:--|:--| + + {% set x = state_attr("sensor.pvopt_opt_cost","Summary")%}{%for y + in x%}|{{y}}|{{('%0.2f' | + format(x[y]['cost']))}}|{{x[y]['Selected']}}| + + {%endfor%} + + +

Charge Plan

@@ -98,7 +113,7 @@ views: |:-------|--|--|:---------|--|--|:--------:|--|--|:--------:|--|--|:----------:|:--|{% for a in state_attr('sensor.pvopt_charge_start', 'windows') %} - {% set tf = '%d-%b %H:%M'%} | + {% set tf = '%d-%b %H:%M %Z'%} | {{as_local(as_datetime(a['start'])).strftime(tf)}} ||| {{as_local(as_datetime(a['end'])).strftime(tf)}} ||| {{a['forced'] | float | round(0)}}W ||| {{a['soc'] | float | round(1)}}% ||| @@ -113,19 +128,40 @@ views: - entity: sensor.pvopt_status name: Status - type: markdown - content: Control Parameters + content:

Control Parameters - type: entities entities: - - entity: switch.pvopt_forced_discharge - name: Optimise Discharging - - entity: switch.pvopt_allow_cyclic - name: Allow Cyclic Charge/Discharge - - entity: switch.pvopt_read_only - name: Read Only Mode - entity: number.pvopt_optimise_frequency_minutes name: Optimiser Freq (mins) + - entity: switch.pvopt_read_only + name: Read Only Mode + - entity: switch.pvopt_include_export + name: Include Export + - type: conditional + conditions: + - condition: state + entity: switch.pvopt_include_export + state: 'on' + card: + type: entities + entities: + - entity: switch.pvopt_forced_discharge + name: Optimise Discharging + - type: conditional + conditions: + - condition: state + entity: switch.pvopt_forced_discharge + state: 'on' + - condition: state + entity: switch.pvopt_include_export + state: 'on' + card: + type: entities + entities: + - entity: switch.pvopt_allow_cyclic + name: Allow Cyclic Charge/Discharge - type: markdown - content: Solar + content:

Solar - type: entities entities: - entity: switch.pvopt_use_solar @@ -139,9 +175,9 @@ views: type: entities entities: - entity: number.pvopt_solcast_confidence_level - name: Solcast Confidence Level + name: Solcast Confidence Level - type: markdown - content: Consumption + content:

Consumption and EV - type: entities entities: - entity: switch.pvopt_use_consumption_history @@ -172,20 +208,24 @@ views: name: Daily Consumption (kWh) - entity: switch.pvopt_shape_consumption_profile name: Shape Consumption Profile - - type: markdown - content: Tuning Parameters - type: entities entities: - - entity: number.pvopt_pass_threshold_p - name: Charge Threshold per pass (p) - - entity: number.pvopt_discharge_threshold_p - name: Discharge Threshold per pass (p) - - entity: number.pvopt_slot_threshold_p - name: Threshold per slot (p) - - entity: number.pvopt_forced_power_group_tolerance - name: Power Resolution + - entity: select.pvopt_ev_charger + name: EV Charger + - type: conditional + conditions: + - condition: state + entity: select.pvopt_ev_charger + state_not: None + card: + type: entities + entities: + - entity: number.pvopt_ev_charger_power_watts + name: EV Charger Power + - entity: number.pvopt_ev_battery_capacity_kwh + name: EV Battery Capacity - type: markdown - content: System Parameters + content:

System Parameters - type: entities entities: - entity: number.pvopt_battery_capacity_wh @@ -198,6 +238,22 @@ views: name: Inverter Efficiency - entity: number.pvopt_charger_efficiency_percent name: Charger Efficiency + - entity: number.pvopt_battery_current_limit_amps + name: Battery Current Limit + - type: markdown + content: Tuning Parameters + - type: entities + entities: + - entity: number.pvopt_pass_threshold_p + name: Charge threshold (p) + - entity: number.pvopt_discharge_threshold_p + name: Discharge threshold (p) + - entity: number.pvopt_plunge_threshold_p_kwh + name: Plunge threshold (p/kWh) + - entity: number.pvopt_slot_threshold_p + name: Threshold per slot (p) + - entity: number.pvopt_forced_power_group_tolerance + name: Power Resolution - type: custom:stack-in-card cards: - type: vertical-stack @@ -244,7 +300,14 @@ views: span: start: day series: + - entity: sensor.solis_total_load + float_precision: 0 + extend_to: now + group_by: + func: avg + duration: 30min - entity: sensor.solis_pv_total_power + float_precision: 0 extend_to: now name: Actual stroke_width: 1 @@ -266,7 +329,6 @@ views: color: yellow opacity: 0.7 stroke_width: 2 - unit: '%' offset: +15min show: in_header: false @@ -444,15 +506,17 @@ views: - id: price decimals: 0 min: -10 - max: 60 + max: 70 apex_config: - tickAmount: 7 + tickAmount: 8 - id: charge decimals: 0 opposite: true show: true min: -4000 max: 4000 + apex_config: + tickAmount: 8 stacked: false span: start: day @@ -475,7 +539,7 @@ views: return [new Date(entry.period_start), entry.forced]; }); - entity: >- - event.octopus_energy_electricity_19m1234567_1610012345678_current_day_rates + event.octopus_energy_electricity_19m1337498_1610032016836_current_day_rates yaxis_id: price name: Historic Import Price color: yellow @@ -489,10 +553,11 @@ views: }); offset: '-15min' show: + in_header: false legend_value: false offset_in_name: false - entity: >- - event.octopus_energy_electricity_19m1234567_1650012345678_export_current_day_rates + event.octopus_energy_electricity_19m1337498_1650000216929_export_current_day_rates yaxis_id: price name: Historic Export Price color: cyan @@ -506,19 +571,21 @@ views: }); offset: '-15min' show: + in_header: false legend_value: false offset_in_name: false - entity: sensor.pvopt_opt_cost type: line name: Future Import Price + float_precision: 1 color: white opacity: 1 stroke_width: 3 extend_to: now - unit: W + unit: p/kWh offset: '-15min' show: - in_header: false + in_header: true legend_value: false offset_in_name: false data_generator: | @@ -527,21 +594,82 @@ views: }); yaxis_id: price - entity: sensor.pvopt_opt_cost + float_precision: 1 type: line name: Future Export Price color: green opacity: 1 stroke_width: 3 extend_to: now - unit: W + unit: p/kWh offset: '-15min' show: - in_header: false + in_header: true legend_value: false offset_in_name: false data_generator: | return entity.attributes.export.map((entry) => { return [new Date(entry.period_start), entry.export]; }); - yaxis_id: price -title: PV Opt + yaxis_id: price + - type: custom:octopus-energy-rates-card + currentEntity: >- + event.octopus_energy_electricity_19m1337498_1610032016836_current_day_rates + futureEntity: >- + event.octopus_energy_electricity_19m1337498_1610032016836_next_day_rates + cols: 2 + hour12: false + showday: true + showpast: false + title: Octopus Import + unitstr: p + lowlimit: 15 + mediumlimit: 20 + highlimit: 30 + roundUnits: 2 + cheapest: true + multiplier: 100 + - type: custom:stack-in-card + cards: + - type: custom:apexcharts-card + apex_config: + chart: + height: 234px + header: + show: true + show_states: true + colorize_states: true + title: Optimised Daily Cost + graph_span: 7d + span: + start: day + offset: '-6d' + series: + - entity: sensor.pvopt_opt_cost_actual + name: Actual + extend_to: false + curve: stepline + float_precision: 2 + show: + legend_value: false + - entity: sensor.pvopt_opt_cost_current + name: Current Contract + curve: stepline + float_precision: 2 + extend_to: false + show: + legend_value: false + - entity: sensor.pvopt_opt_cost_eco7_fix + name: Eco7 / Fix + curve: stepline + float_precision: 2 + extend_to: false + show: + legend_value: false + - entity: sensor.pvopt_opt_cost_flux + curve: stepline + name: Flux + float_precision: 2 + show: + legend_value: false + extend_to: false \ No newline at end of file diff --git a/pvopt_test_card.yaml b/pvopt_test_card.yaml new file mode 100644 index 0000000..b2497dc --- /dev/null +++ b/pvopt_test_card.yaml @@ -0,0 +1,17 @@ +type: entities +entities: + - entity: select.pvopt_test_function + name: Function + - entity: select.pvopt_test_enable + name: Enable / Disable + - entity: text.pvopt_test_start + name: Start Time (Local Time Zone) + - entity: text.pvopt_test_end + name: End Time (Local Time Zone) + - entity: number.pvopt_test_power + name: Power + - entity: number.pvopt_test_target_soc + name: Target SOC + - entity: button.pvopt_test_button + name: Send to Inverter +title: PV Opt Test Card