Skip to content

Commit

Permalink
Merge pull request #135 from fboundy/dev
Browse files Browse the repository at this point in the history
Dev
  • Loading branch information
fboundy authored Feb 23, 2024
2 parents b392b01 + 61ec8f9 commit 2ca8476
Show file tree
Hide file tree
Showing 3 changed files with 121 additions and 49 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.9.0
# PV Opt: Home Assistant Solar/Battery Optimiser v3.9.1

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
65 changes: 37 additions & 28 deletions apps/pv_opt/pv_opt.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
#
USE_TARIFF = True

VERSION = "3.9.0"
VERSION = "3.9.1"
DEBUG = False

DATE_TIME_FORMAT_LONG = "%Y-%m-%d %H:%M:%S%z"
Expand Down Expand Up @@ -1520,10 +1520,13 @@ def optimise(self):
):
# Next slot starts before the next optimiser run. This implies we are not currently in
# a charge or discharge slot

self.log(
f"Next charge/discharge window starts in {time_to_slot_start:0.1f} minutes."
)

if len(self.windows > 0):
self.log(
f"Next charge/discharge window starts in {time_to_slot_start:0.1f} minutes."
)
else:
self.log("No charge/discharge windows planned.")

if self.charge_power > 0:
self.inverter.control_charge(
Expand All @@ -1545,7 +1548,7 @@ def optimise(self):

elif (time_to_slot_start <= 0) and (
time_to_slot_start < self.get_config("optimise_frequency_minutes")
):
) and (len(self.windows) > 0):
# We are currently in a charge/discharge slot

# If the current slot is a Hold SOC slot and we aren't holding then we need to
Expand All @@ -1566,7 +1569,7 @@ def optimise(self):

else:
self.log(
f"Current charge/discharge windows ends in {time_to_slot_end:0.1f} minutes."
f"Current charge/discharge window ends in {time_to_slot_end:0.1f} minutes."
)

if self.charge_power > 0:
Expand Down Expand Up @@ -1616,7 +1619,11 @@ def optimise(self):
# We aren't in a charge/discharge slot and the next one doesn't start before the
# optimiser runs again

str_log = f"Next {direction} window starts in {time_to_slot_start:0.1f} minutes "
if len(self.windows) > 0:
str_log = f"Next {direction} window starts in {time_to_slot_start:0.1f} minutes "

else:
str_log = "No charge/discharge windows planned "

# If the next slot isn't soon then just check that current status matches what we see:
did_something = False
Expand All @@ -1643,25 +1650,26 @@ def optimise(self):
self.inverter.control_charge(enable=False)
did_something = True

if (
direction == "charge"
and self.charge_start_datetime > status["discharge"]["start"]
and status["discharge"]["start"] != status["discharge"]["end"]
):
str_log += " but inverter is has a discharge slot before then. Disabling discharge."
self.log(str_log)
self.inverter.control_discharge(enable=False)
did_something = True

elif (
direction == "discharge"
and self.charge_start_datetime > status["charge"]["start"]
and status["charge"]["start"] != status["charge"]["end"]
):
str_log += " but inverter is has a charge slot before then. Disabling charge."
self.log(str_log)
self.inverter.control_charge(enable=False)
did_something = True
if len(self.windows) > 0:
if (
direction == "charge"
and self.charge_start_datetime > status["discharge"]["start"]
and status["discharge"]["start"] != status["discharge"]["end"]
):
str_log += " but inverter has a discharge slot before then. Disabling discharge."
self.log(str_log)
self.inverter.control_discharge(enable=False)
did_something = True

elif (
direction == "discharge"
and self.charge_start_datetime > status["charge"]["start"]
and status["charge"]["start"] != status["charge"]["end"]
):
str_log += " but inverter is has a charge slot before then. Disabling charge."
self.log(str_log)
self.inverter.control_charge(enable=False)
did_something = True

if status["hold_soc"]["active"]:
self.inverter.hold_soc(enable=False)
Expand All @@ -1676,7 +1684,7 @@ 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"Wating for Modbus Read cycle: {i} seconds")
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)
Expand Down Expand Up @@ -1781,6 +1789,7 @@ def _create_windows(self):
self.charge_start_datetime = self.static.index[0]
self.charge_end_datetime = self.static.index[0]
self.hold = []
self.windows = pd.DataFrame()

def _log_inverter_status(self, status):
self.log("")
Expand Down
103 changes: 83 additions & 20 deletions apps/pv_opt/pvpy.py
Original file line number Diff line number Diff line change
Expand Up @@ -595,7 +595,7 @@ def optimised_force(self, initial_soc, static_flows, contract: Contract, **kwarg
[prices, consumption, self.flows(initial_soc, static_flows, **kwargs)],
axis=1,
)
base_cost = contract.net_cost(df).sum()
base_cost = round(contract.net_cost(df).sum(),1)
net_cost = []
net_cost_opt = base_cost

Expand Down Expand Up @@ -674,6 +674,7 @@ def optimised_force(self, initial_soc, static_flows, contract: Contract, **kwarg
start_window = window[0]

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" SOC: {x.loc[window[0]]['soc']:5.1f}%->{x.loc[window[-1]]['soc_end']:5.1f}% "
factors = []
Expand Down Expand Up @@ -744,22 +745,71 @@ def optimised_force(self, initial_soc, static_flows, contract: Contract, **kwarg
self.log("No slots available")
done = True

z = pd.DataFrame(data={"net_cost": net_cost, "slot_count": slot_count})
z["slot_total"] = z["slot_count"].cumsum()
z["delta"] = z["net_cost"].diff()
max_delta = z["net_cost"].diff().iloc[1:].max()
if log:
self.log("")
self.log(f"Maximum 1st pass slot delta is {max_delta:0.1f}p")
self.log("")
# self.log("")
# self.log("Merging Charging Slots")
# self.log("----------------------")

# z = pd.DataFrame(data={"net_cost": net_cost, "slot_count": slot_count})
# z["slot_total"] = z["slot_count"].cumsum()
# z["delta"] = z["net_cost"].diff()
# self.log(z)
# # max_delta = z["net_cost"].diff().iloc[1:].max()
# slot_df =pd.DataFrame(slots).set_index(0)
# slot_df['delta'] = [b for a in [[x[1]] * x[0] for x in zip(z["slot_count"].to_list(),z["delta"].to_list())] for b in a]
# self.log(slot_df)
# slot_df = slot_df.groupby(slot_df.index).sum().merge(right=df['import'], left_index=True, right_index=True).sort_values(['delta','import'])

# self.log(slot_df)
# new_slots = slot_df.to_dict()[1]
# new_slots = [(x, new_slots[x]) for x in new_slots]

# i = 1
# net_cost = [base_cost]
# slot_threshold = self.host.get_config("slot_threshold_p")
# self.log(slot_threshold)
# self.log(base_cost)
# while i<=len(new_slots):
# df = pd.concat(
# [
# prices,
# consumption,
# self.flows(
# initial_soc, static_flows, slots=new_slots[:i], **kwargs
# ),
# ],
# axis=1,
# )
# net_cost.append(round(contract.net_cost(df).sum(), 1))
# self.log(f"{i}: {new_slots[i-1]} {net_cost[-1]} {net_cost[-2]} {net_cost[-1]-net_cost[-2]}" )
# i += 1

# slots = [x[0] for x in zip(new_slots, net_cost[1:], net_cost[:-1]) if x[2]-x[1] >=slot_threshold]
# self.log(slots)


df = pd.concat(
[
prices,
self.flows(initial_soc, static_flows, slots=slots, **kwargs),
consumption,
self.flows(
initial_soc, static_flows, slots=slots, **kwargs
),
],
axis=1,
)
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")
slots = []
net_cost_opt = base_cost
df = pd.concat(
[
prices,
self.flows(initial_soc, static_flows, slots=slots, **kwargs),
],
axis=1,
)

slots_added = 999
j = 0
Expand Down Expand Up @@ -899,9 +949,9 @@ def optimised_force(self, initial_soc, static_flows, contract: Contract, **kwarg
slots = slots_pre
slots_added = 0
net_cost_opt = net_cost_pre
str_log += f": < threshold {self.host.get_config('pass_threshold_p')} => Excluded"
str_log += f": < Pass Threshold {self.host.get_config('pass_threshold_p'):0.1f}p => Slots Excluded"
else:
str_log += f": > threshold {self.host.get_config('pass_threshold_p')} => Included"
str_log += f": > Pass Threshold {self.host.get_config('pass_threshold_p'):0.1f}p => Slots Included"

if log:
self.log("")
Expand Down Expand Up @@ -1025,10 +1075,10 @@ def optimised_force(self, initial_soc, static_flows, contract: Contract, **kwarg
if cost_delta > -self.host.get_config("pass_threshold_p"):
slots = slots_pre
slots_added = slots_added_pre
str_log += f": < threshold ({self.host.get_config('pass_threshold_p')}) => Excluded"
str_log += f": < Pass threshold ({self.host.get_config('pass_threshold_p'):0.1f}p) => Slots excluded"
net_cost_opt = net_cost_pre
else:
str_log += f": > threshold ({self.host.get_config('pass_threshold_p')}) => Included"
str_log += f": > Pass Threshold ({self.host.get_config('pass_threshold_p'):0.1f}p) => Slots included"

if log:
self.log("")
Expand All @@ -1037,29 +1087,42 @@ def optimised_force(self, initial_soc, static_flows, contract: Contract, **kwarg
if log:
self.log(f"Iteration {j:2d}: Slots added: {slots_added:3d}")

df = pd.concat(
[
prices,
self.flows(
initial_soc, static_flows, slots=slots, **kwargs
),
],
axis=1,
)
df.index = pd.to_datetime(df.index)

if not self.host.get_config("allow_cyclic"):
if (not self.host.get_config("allow_cyclic")) and (len(slots) > 0) and discharge:
if log:
self.log("")
self.log("Removing cyclic charge/discharge")
a = df["forced"][df["forced"] != 0].to_dict()
new_slots = [(k, a[k]) for k in a]

revised_slots = []
skip_flag = False
for slot, next_slot in zip(new_slots[:-1], new_slots[1:]):
if (int(slot[1]) == self.inverter.charger_power) & (
int(-next_slot[1]) == self.inverter.inverter_power
for i, x in enumerate(zip(new_slots[:-1], new_slots[1:])):

if (int(x[0][1]) == self.inverter.charger_power) & (
int(-x[1][1]) == self.inverter.inverter_power
):
skip_flag = True
if log:
self.log(
f" Skipping slots at {slot[0].strftime(TIME_FORMAT)} ({slot[1]}W) and {next_slot[0].strftime(TIME_FORMAT)} ({next_slot[1]}W)"
f" Skipping slots at {x[0][0].strftime(TIME_FORMAT)} ({x[0][1]}W) and {x[1][0].strftime(TIME_FORMAT)} ({x[1][1]}W)"
)
elif skip_flag:
skip_flag = False
else:
revised_slots.append(slot)
revised_slots.append(x[0])
if i == len(new_slots)-2:
revised_slots.append(x[1])

df = pd.concat(
[
Expand Down

0 comments on commit 2ca8476

Please sign in to comment.