Skip to content

Commit

Permalink
Merge pull request #153 from fboundy/dev
Browse files Browse the repository at this point in the history
Dev
  • Loading branch information
fboundy authored Mar 18, 2024
2 parents dd1df0c + da603c4 commit 45a9456
Show file tree
Hide file tree
Showing 5 changed files with 97 additions and 105 deletions.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -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.

Expand Down
4 changes: 4 additions & 0 deletions apps/pv_opt/config/config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@ pvpy:
module: pvpy
global: true

solis:
module: solis
global: true

inverters:
module: inverters
global: true
Expand Down
32 changes: 28 additions & 4 deletions apps/pv_opt/pv_opt.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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": {
Expand Down Expand Up @@ -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"},
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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:
Expand All @@ -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,
)
Expand Down Expand Up @@ -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)
Expand Down
160 changes: 62 additions & 98 deletions apps/pv_opt/solis.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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:
Expand All @@ -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()

Expand All @@ -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"],
Expand All @@ -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
Expand All @@ -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))
Expand All @@ -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):
Expand Down Expand Up @@ -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"
Expand All @@ -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(
Expand All @@ -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")
Expand All @@ -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")
Expand Down Expand Up @@ -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}

Expand Down Expand Up @@ -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
Expand All @@ -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)
4 changes: 2 additions & 2 deletions pvopt_dashboard.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down

0 comments on commit 45a9456

Please sign in to comment.