diff --git a/.github/workflows/auto_release.yaml b/.github/workflows/auto_release.yaml index 7bdd93e..ffec378 100644 --- a/.github/workflows/auto_release.yaml +++ b/.github/workflows/auto_release.yaml @@ -64,15 +64,15 @@ jobs: RELEASE_NOTES+="No significant changes." else echo "Found commits:" - echo -e "${COMMITS}" - RELEASE_NOTES+=$(echo -e "${COMMITS}") + echo "${COMMITS}" + RELEASE_NOTES+="${COMMITS}" fi # Output the release notes - echo -e "Release notes generated:\n${RELEASE_NOTES}" - echo "${RELEASE_NOTES}" > release_notes.txt - echo "RELEASE_NOTES=$(> $GITHUB_ENV - + echo "Release notes generated:"\n"${RELEASE_NOTES}" + echo "release_notes<> $GITHUB_ENV + echo "${RELEASE_NOTES}" >> $GITHUB_ENV + echo "EOF" >> $GITHUB_ENV # Step 5: Create GitHub Release - name: Create GitHub Release diff --git a/.github/workflows/black.yaml b/.github/workflows/black.yaml index f3c02db..dbc6af6 100644 --- a/.github/workflows/black.yaml +++ b/.github/workflows/black.yaml @@ -29,7 +29,7 @@ jobs: - name: Format Code with Black and isort run: | black --line-length=119 . - isort . + isort --profile="black" . # Step 5: Commit Formatting Changes - name: Commit Formatting Changes @@ -65,25 +65,16 @@ jobs: exit 0 fi - - name: Fetch main branch into a temporary branch + - name: Get Latest Tag + id: get_latest_tag run: | - git fetch origin main - git checkout -b temp-main origin/main - - - name: Get VERSION from Main Branch - id: get_main_version - run: | - VERSION=$(grep -m 1 -oP '(?<=^VERSION = ")[^"]+' apps/pv_opt/pv_opt.py) - if [ -z "$VERSION" ]; then - echo "Error: VERSION not found in apps/pv_opt/pv_opt.py on main branch." >&2 + LATEST_TAG=$(git describe --tags --abbrev=0) + if [ -z "$LATEST_TAG" ]; then + echo "Error: No tags found in the repository." >&2 exit 1 fi - echo "main_version=$VERSION" >> $GITHUB_ENV - - - name: Switch Back to Source Branch - run: | - git fetch origin $GITHUB_HEAD_REF - git checkout $GITHUB_HEAD_REF + VERSION=$(echo "$LATEST_TAG" | sed -E 's/^v?([0-9]+\.[0-9]+\.[0-9]+)$/\1/') + echo "tag_version=$VERSION" >> $GITHUB_ENV - name: Get VERSION from Current Branch id: get_patch_version @@ -99,16 +90,16 @@ jobs: id: validate_or_fix_version run: | patch_version=$patch_version - main_version=$main_version + tag_version=$tag_version - main_major=$(echo "$main_version" | awk -F '.' '{print $1}') - main_minor=$(echo "$main_version" | awk -F '.' '{print $2}') - main_patch=$(echo "$main_version" | awk -F '.' '{print $3}') + tag_major=$(echo "$tag_version" | awk -F '.' '{print $1}') + tag_minor=$(echo "$tag_version" | awk -F '.' '{print $2}') + tag_patch=$(echo "$tag_version" | awk -F '.' '{print $3}') if [[ "$GITHUB_HEAD_REF" == patch* ]]; then - new_patch_version="$main_major.$main_minor.$((main_patch + 1))" + new_patch_version="$tag_major.$tag_minor.$((tag_patch + 1))" elif [[ "$GITHUB_HEAD_REF" == dev* ]]; then - new_patch_version="$main_major.$((main_minor + 1)).0" + new_patch_version="$tag_major.$((tag_minor + 1)).0" else echo "Error: Unsupported source branch type." >&2 exit 1 @@ -131,6 +122,7 @@ jobs: run: | git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" + git checkout -B $GITHUB_HEAD_REF git add apps/pv_opt/pv_opt.py README.md if git diff --cached --quiet; then echo "No version changes to commit." diff --git a/README.md b/README.md index 0d39752..51f2cdc 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# PV Opt: Home Assistant Solar/Battery Optimiser v4.0.5 +# PV Opt: Home Assistant Solar/Battery Optimiser v4.0.7 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 9bc591e..b78af3c 100644 --- a/apps/pv_opt/config/config.yaml +++ b/apps/pv_opt/config/config.yaml @@ -121,7 +121,7 @@ pv_opt: # ======================================== # Octopus account parameters # ======================================== - # octopus_auto: False # Read tariffs from the Octopus Energy integration. If successful this over-rides the following parameters + octopus_auto: False # Read tariffs from the Octopus Energy integration. If successful this over-rides the following parameters # octopus_account: !secret octopus_account # octopus_api_key: !secret octopus_api_key @@ -159,12 +159,12 @@ pv_opt: # octopus_import_tariff_code: E-1R-AGILE-23-12-06-G # # octopus_export_tariff_code: E-1R-OUTGOING-LITE-FIX-12M-23-09-12-G - # octopus_export_tariff_code: E-1R-OUTGOING-FIX-12M-19-05-13-G + octopus_export_tariff_code: E-1R-OUTGOING-FIX-12M-19-05-13-G # octopus_import_tariff_code: E-1R-FLUX-IMPORT-23-02-14-G # octopus_export_tariff_code: E-1R-FLUX-EXPORT-23-02-14-G - # octopus_import_tariff_code: E-1R-GO-VAR-22-10-14-N + octopus_import_tariff_code: E-1R-GO-VAR-22-10-14-N # octopus_export_tariff_code: E-1R-OUTGOING-LITE-FIX-12M-23-09-12-N # ======================================== @@ -444,19 +444,19 @@ pv_opt: # Tariff comparison # id_daily_solar: sensor.{device_name}_power_generation_today - # id_solar_power: - # # - sensor.{device_name}_pv_power_1 - # # - sensor.{device_name}_pv_power_2 - # alt_tariffs: - # - name: Agile_Fix - # octopus_import_tariff_code: E-1R-AGILE-23-12-06-G - # octopus_export_tariff_code: E-1R-OUTGOING-FIX-12M-19-05-13-G - - # # - 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 + id_solar_power: + - sensor.{device_name}_pv_power_1 + - sensor.{device_name}_pv_power_2 + alt_tariffs: + - name: Agile_Fix + octopus_import_tariff_code: E-1R-AGILE-23-12-06-G + octopus_export_tariff_code: E-1R-OUTGOING-FIX-12M-19-05-13-G + + # - 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 diff --git a/apps/pv_opt/pv_opt.py b/apps/pv_opt/pv_opt.py index 03ee394..5ca6d5a 100644 --- a/apps/pv_opt/pv_opt.py +++ b/apps/pv_opt/pv_opt.py @@ -13,12 +13,13 @@ import pvpy as pv from numpy import nan -VERSION = "4.0.5" +VERSION = "4.0.7" UNITS = { "current": "A", "power": "W", } + OCTOPUS_PRODUCT_URL = r"https://api.octopus.energy/v1/products/" DEBUG = False @@ -2354,66 +2355,69 @@ def optimise(self): return self.pv_system.static_flows = pd.concat([solcast, consumption], axis=1) - self.time_now = pd.Timestamp.utcnow() + self.time_now = pd.Timestamp.utcnow().floor("1min") self.pv_system.static_flows = self.pv_system.static_flows[self.time_now.floor("30min") :].fillna(0) + self.pv_system.static_flows.index = [self.time_now] + list(self.pv_system.static_flows.index[1:]) soc_now = self.get_config("id_battery_soc") - soc_last_day = self.hass2df(self.config["id_battery_soc"], days=1, log=self.debug) - if self.debug and "S" in self.debug_cat: - self.log(f">>> soc_now: {soc_now}") - self.log(f">>> soc_last_day: {soc_last_day}") - self.log( - f">>> Original: {soc_last_day.loc[soc_last_day.loc[: self.pv_system.static_flows.index[0]].index[-1] :]}" - ) - - try: - soc_now = float(soc_now) - - except: - self.log("") - self.log( - "Unable to get current SOC from HASS. Using last value from History.", - level="WARNING", - ) - soc_now = soc_last_day.iloc[-1] - - # x = x.astype(float) - - try: - soc_last_day = pd.to_numeric(soc_last_day, errors="coerce").interpolate() + self.pv_system.initial_soc = soc_now + # soc_last_day = self.hass2df(self.config["id_battery_soc"], days=1, log=self.debug) + # if self.debug and "S" in self.debug_cat: + # self.log(f">>> soc_now: {soc_now}") + # self.log(f">>> soc_last_day: {soc_last_day}") + # self.log( + # f">>> Original: {soc_last_day.loc[soc_last_day.loc[: self.pv_system.static_flows.index[0]].index[-1] :]}" + # ) + + # try: + # soc_now = float(soc_now) + + # except: + # self.log("") + # self.log( + # "Unable to get current SOC from HASS. Using last value from History.", + # level="WARNING", + # ) + # soc_now = soc_last_day.iloc[-1] + + # # x = x.astype(float) + + # try: + # soc_last_day = pd.to_numeric(soc_last_day, errors="coerce").interpolate() + + # soc_last_day = soc_last_day.loc[soc_last_day.loc[: self.pv_system.static_flows.index[0]].index[-1] :] + # if self.debug and "S" in self.debug_cat: + # self.log( + # f">>> Fixed : {soc_last_day.loc[soc_last_day.loc[: self.pv_system.static_flows.index[0]].index[-1] :]}" + # ) + + # soc_last_day = pd.concat( + # [ + # soc_last_day, + # pd.Series( + # data=[soc_now, nan], + # index=[self.time_now, self.pv_system.static_flows.index[0]], + # ), + # ] + # ).sort_index() + # self.pv_system.initial_soc = soc_last_day.interpolate().loc[self.pv_system.static_flows.index[0]] + # except: + # self.pv_system.initial_soc = None + + # if not isinstance(self.pv_system.initial_soc, float): + # self.log("") + # self.log( + # "Unable to retrieve initial SOC - assuming it is the same as current SOC", + # level="WARNING", + # ) + # self.pv_system.initial_soc = soc_now + + # self.pv_system.soc_now = (self.time_now, soc_now) - soc_last_day = soc_last_day.loc[soc_last_day.loc[: self.pv_system.static_flows.index[0]].index[-1] :] - if self.debug and "S" in self.debug_cat: - self.log( - f">>> Fixed : {soc_last_day.loc[soc_last_day.loc[: self.pv_system.static_flows.index[0]].index[-1] :]}" - ) - - soc_last_day = pd.concat( - [ - soc_last_day, - pd.Series( - data=[soc_now, nan], - index=[self.time_now, self.pv_system.static_flows.index[0]], - ), - ] - ).sort_index() - self.pv_system.initial_soc = soc_last_day.interpolate().loc[self.pv_system.static_flows.index[0]] - except: - self.pv_system.initial_soc = None - - if not isinstance(self.pv_system.initial_soc, float): - self.log("") - self.log( - "Unable to retrieve initial SOC - assuming it is the same as current SOC", - level="WARNING", - ) - self.pv_system.initial_soc = soc_now - - self.pv_system.soc_now = (self.time_now, soc_now) - - self.log("") - self.log(f"Initial SOC: {self.pv_system.initial_soc}") + # self.log("") + # self.log(f"Initial SOC: {self.pv_system.initial_soc}") + # self.log(f"Current SOC: {self.pv_system.soc_now}") self.pv_system.calculate_flows() self.flows = {"Base": self.pv_system.flows} @@ -2506,6 +2510,8 @@ def optimise(self): self.opt = self.flows[self.selected_case] + self.base = self.flows["Base"] + # SVB debug logging # self.log("") # self.log("Returned from .flows. self.opt is........") @@ -3986,10 +3992,19 @@ def _compare_tariffs(self): return consumption = self.load_consumption(start, end) - static = pd.concat([solar, consumption], axis=1).set_axis(["solar", "consumption"], axis=1) + self.pv_system.static_flows = pd.concat([solar, consumption], axis=1).set_axis( + ["solar", "consumption"], axis=1 + ) initial_soc_df = self.hass2df(self.config["id_battery_soc"], days=2, freq="30min") - initial_soc = initial_soc_df.loc[start] + self.pv_system.initial_soc = initial_soc_df.loc[start] + + # Not sure about the next lines, but calculate_flows requires soc_now + soc_now = self.get_config("id_battery_soc") + self.time_now = pd.Timestamp.utcnow() + self.pv_system.soc_now = (self.time_now, soc_now) + + # self.pv_system.soc_now = initial_soc_df.loc[start] self.pv_system.calculate_flows() base = self.pv_system.flows @@ -3999,12 +4014,12 @@ def _compare_tariffs(self): 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") + self.log(f"Initial SOC: {self.pv_system.initial_soc:>15.1f}%") + self.log(f"Consumption: {self.pv_system.static_flows['consumption'].sum()/2000:15.1f} kWh") + self.log(f"Solar: {self.pv_system.static_flows['solar'].sum()/2000:15.1f} kWh") if self.debug and "T" in self.debug_cat: - self.log(f">>> Yesterday's data:\n{static.to_string()}") + self.log(f">>> Yesterday's data:\n{self.pv_system.static_flows.to_string()}") for tariff_set in self.config["alt_tariffs"]: code = {} @@ -4024,7 +4039,9 @@ 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" + self.pv_system.static_flows["period_start"] = ( + self.pv_system.static_flows.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), @@ -4035,7 +4052,10 @@ def _compare_tariffs(self): "unit_of_measurement": "GBP", "friendly_name": f"PV Opt Comparison Actual", } - | {col: static[["period_start", col]].to_dict("records") for col in ["solar", "consumption"]}, + | { + col: self.pv_system.static_flows[["period_start", col]].to_dict("records") + for col in ["solar", "consumption"] + }, ) self.ulog("Net Cost comparison:", underline=None) diff --git a/apps/pv_opt/pvpy.py b/apps/pv_opt/pvpy.py index dd45e54..dfe06d9 100644 --- a/apps/pv_opt/pvpy.py +++ b/apps/pv_opt/pvpy.py @@ -60,6 +60,12 @@ # outputs self.unit. +def get_dt_hours(df: pd.DataFrame | pd.Series) -> pd.Series: + df = pd.DataFrame(df) + df["dt_hours"] = -df.index.diff(-1) / pd.Timedelta("60min") + return df["dt_hours"].ffill() + + class Tariff: def __init__( self, @@ -288,64 +294,9 @@ def to_df(self, start=None, end=None, **kwargs): df = pd.DataFrame(self.unit).set_index("valid_from")["value_inc_vat"] df.index = pd.to_datetime(df.index) df = df.sort_index() - - # df at this point is a series of start and end times and not a series of 1/2 hour slots - - # SVB logging - # self.log("Df loaded from self.unit") - # self.log(df) - # self.log("") - # self.log("Printing Df") - # self.log(df.to_string()) - if "AGILE" in self.name and use_day_ahead: - # if self.day_ahead is not None and df.index[-1].day == end.day: - # # reset the day ahead forecasts if we've got a forecast going into tomorrow - # self.agile_predict = None - # self.day_ahead = None - # self.log("") - # self.log(f"Cleared day ahead forecast for tariff {self.name}") - - # if pd.Timestamp.now(tz=self.tz).hour > 11 and df.index[-1].day != 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]) - # if self.day_ahead is not None: - # self.day_ahead = self.day_ahead.sort_index() - # self.log("") - # self.log( - # f"Retrieved day ahead forecast for period {self.day_ahead.index[0].strftime(TIME_FORMAT)} - {self.day_ahead.index[-1].strftime(TIME_FORMAT)} for tariff {self.name}" - # ) - - # if self.day_ahead is not None: - # if self.export: - # factors = AGILE_FACTORS["export"][self.area] - # else: - # factors = AGILE_FACTORS["import"][self.area] - - # mask = (self.day_ahead.index.tz_convert("GB").hour >= 16) & ( - # self.day_ahead.index.tz_convert("GB").hour < 19 - # ) - - # agile = ( - # pd.concat( - # [ - # (self.day_ahead[mask] * factors[0] + factors[1] + factors[2]), - # (self.day_ahead[~mask] * factors[0] + factors[1]), - # ] - # ) - # .sort_index() - # .loc[df.index[-1] :] - # .iloc[1:] - # ) - - # df = pd.concat([df, agile]) - # else: - # Otherwise download the latest forecast from AgilePredict if self.agile_predict is None: self.agile_predict = self._get_agile_predict() - # self.log("Agile_predict is") - # self.log(f"\n{self.agile_predict}") if self.agile_predict is not None: df = pd.concat( @@ -388,9 +339,6 @@ def to_df(self, start=None, end=None, **kwargs): # Add IO slot prices as a column to dataframe. df = pd.concat([df, self.host.io_prices], axis=1).set_axis(["unit", "io_unit"], axis=1) - # self.log("To_df, Printing concat") - # self.log(df.to_string()) - df = df.dropna(subset=["unit"]) # Drop Nans mask = df["io_unit"] < df["unit"] # Mask is true if an IOslot df.loc[mask, "unit"] = df[ @@ -698,15 +646,17 @@ def net_cost(self, grid_flow, sum=True, decimals=1, **kwargs): grid_imp = grid_flow.clip(0) grid_exp = grid_flow.clip(upper=0) + dt = get_dt_hours(grid_flow) + imp_df = self.tariffs["import"].to_df(start, end, **kwargs) nc = imp_df["fixed"] if kwargs.get("log") and (self.host.debug and "F" in self.host.debug_cat): self.rlog(f">>> Import{self.tariffs['import'].to_df(start,end).to_string()}") - nc += imp_df["unit"] * grid_imp / 2000 + nc += imp_df["unit"] * grid_imp / 1000 * dt if kwargs.get("log") and (self.host.debug and "F" in self.host.debug_cat): self.rlog(f">>> Export{self.tariffs['export'].to_df(start,end).to_string()}") if self.tariffs["export"] is not None: - nc += self.tariffs["export"].to_df(start, end, **kwargs)["unit"] * grid_exp / 2000 + nc += self.tariffs["export"].to_df(start, end, **kwargs)["unit"] * grid_exp / 1000 * dt if self.host.debug and "V" in self.host.debug_cat: self.log("") @@ -721,12 +671,14 @@ def net_cost(self, grid_flow, sum=True, decimals=1, **kwargs): def prices(self, start=None, end=None): prices = pd.concat( [ - self.tariffs[direction].to_df(start=start, end=end)["unit"] + self.tariffs[direction].to_df(start=start.floor("30min"), end=end)["unit"] for direction in self.tariffs if self.tariffs[direction] is not None ], axis=1, ) + + prices.index = [start] + list(prices.index[1:]) return prices @@ -753,13 +705,15 @@ def __str__(self): pass def calculate_flows(self, slots=[], solar_id="solar", consumption_id="consumption", **kwargs): - solar = self.static_flows[solar_id] - consumption = self.static_flows[consumption_id] - - self.flows = self.static_flows.copy() + # solar = self.static_flows[solar_id] + # consumption = self.static_flows[consumption_id] - battery_flows = solar - consumption - forced_charge = pd.Series(index=self.flows.index, data=0) + self.flows = self.static_flows.copy()[[solar_id, consumption_id]].set_axis(["solar", "consumption"], axis=1) + self.flows["dt_hours"] = get_dt_hours(self.flows) + self.flows["battery_grid_requirement"] = self.flows["consumption"] - self.flows["solar"] + self.flows["forced"] = 0 + self.flows["battery_temp"] = self.flows["consumption"] - self.flows["solar"] + # forced_charge = pd.Series(index=self.flows.index, data=0) if len(slots) > 0: timed_slot_flows = pd.Series(index=self.flows.index, data=0) @@ -769,19 +723,19 @@ def calculate_flows(self, slots=[], solar_id="solar", consumption_id="consumptio timed_slot_flows.loc[t] += int(c) chg_mask = timed_slot_flows != 0 - battery_flows[chg_mask] = timed_slot_flows[chg_mask] - forced_charge[chg_mask] = timed_slot_flows[chg_mask] + # self.flows["battery_temp"][chg_mask] = -timed_slot_flows[chg_mask] + # self.flows["forced"][chg_mask] = timed_slot_flows[chg_mask] - if self.soc_now is None: - chg = [self.initial_soc / 100 * self.battery.capacity] - freq = pd.infer_freq(self.static_flows.index) / pd.Timedelta(60, "minutes") + self.flows.loc[chg_mask, "battery_temp"] = -timed_slot_flows[chg_mask] + self.flows.loc[chg_mask, "forced"] = timed_slot_flows[chg_mask] - else: - chg = [self.soc_now[1] / 100 * self.battery.capacity] - freq = (self.soc_now[0] - self.flows.index[0]) / pd.Timedelta(60, "minutes") + chg = [self.initial_soc / 100 * self.battery.capacity] + + for idx in self.flows.index: + flow = self.flows["battery_temp"].loc[idx] + dt_hours = self.flows["dt_hours"].loc[idx] - for i, flow in enumerate(battery_flows): - if flow < 0: + if flow > 0: flow = flow / self.inverter.inverter_efficiency else: flow = flow * self.inverter.charger_efficiency @@ -792,7 +746,7 @@ def calculate_flows(self, slots=[], solar_id="solar", consumption_id="consumptio [ min( [ - chg[-1] + flow * freq, + chg[-1] - flow * dt_hours, self.battery.capacity, ] ), @@ -802,29 +756,24 @@ def calculate_flows(self, slots=[], solar_id="solar", consumption_id="consumptio 1, ) ) - if (self.soc_now is not None) and (i == 0): - freq = pd.infer_freq(self.static_flows.index) / pd.Timedelta(60, "minutes") - - if self.soc_now is not None: - chg[0] = self.initial_soc / 100 * self.battery.capacity self.flows["chg"] = chg[:-1] self.flows["chg"] = self.flows["chg"].ffill() self.flows["chg_end"] = chg[1:] self.flows["chg_end"] = self.flows["chg_end"].bfill() - self.flows["battery"] = (pd.Series(chg).diff(-1) / freq)[:-1].to_list() + self.flows["battery"] = pd.Series(chg).diff(-1)[:-1].to_list() + self.flows["battery"] /= self.flows["dt_hours"] self.flows.loc[self.flows["battery"] > 0, "battery"] = ( self.flows["battery"] * self.inverter.inverter_efficiency ) self.flows.loc[self.flows["battery"] < 0, "battery"] = self.flows["battery"] / self.inverter.charger_efficiency - self.flows["grid"] = -(solar - consumption + self.flows["battery"]).round(0) - self.flows["forced"] = forced_charge + self.flows["grid"] = (self.flows["battery_grid_requirement"] - self.flows["battery"]).round(0) self.flows["soc"] = (self.flows["chg"] / self.battery.capacity) * 100 self.flows["soc_end"] = (self.flows["chg_end"] / self.battery.capacity) * 100 if self.prices is not None: self.flows = pd.concat( - [self.prices, consumption, self.flows], + [self.flows, self.prices], axis=1, ) @@ -984,13 +933,17 @@ def _high_cost_swaps(self, log=True): if (i > 96) or (available.sum() == 0): done = True - import_cost = ((self.flows["import"] * self.flows["grid"]).clip(0) / 2000)[~tested] + import_cost = ((self.flows["import"] * self.flows["grid"]).clip(0) * self.flows["dt_hours"] / 1000)[ + ~tested + ] if len(import_cost[self.flows["forced"] == 0]) > 0: max_import_cost = import_cost[self.flows["forced"] == 0].max() if len(import_cost[import_cost == max_import_cost]) > 0: max_slot = import_cost[import_cost == max_import_cost].index[0] - max_slot_energy = round(self.flows["grid"].loc[max_slot] / 2000, 2) # kWh + max_slot_energy = round( + self.flows["grid"].loc[max_slot] / 1000 * self.flows["dt_hours"].loc[max_slot], 2 + ) # kWh str_log = f"{i:3d} {available.sum():3d} {max_slot.tz_convert(self.tz).strftime(TIME_FORMAT)}:" if max_slot_energy > 0: diff --git a/apps/pv_opt/solis.py b/apps/pv_opt/solis.py index b1c4a08..35adfa4 100644 --- a/apps/pv_opt/solis.py +++ b/apps/pv_opt/solis.py @@ -29,11 +29,11 @@ "Feed-in priority": 64, }, False: { - "SelfUse - No Grid Charging": 1, + "Selfuse - No Grid Charging": 1, "Self-Use - No Grid Charging": 1, "Timed Charge/Discharge - No Grid Charging": 3, "Backup/Reserve - No Grid Charging": 17, - "SelfUse": 33, + "Selfuse": 33, "Self-Use - No Timed Charge/Discharge": 33, "Self-Use": 35, "Timed Charge/Discharge": 35, @@ -151,6 +151,7 @@ "id_timed_charge_discharge_button": "button.{device_name}_update_charge_discharge_times", "id_inverter_mode": "select.{device_name}_energy_storage_control_switch", "id_backup_mode_soc": "number.{device_name}_backup_mode_soc", + "id_solar_power": ["sensor.{device_name}_pv_power_1", "sensor.{device_name}_pv_power_2"], }, }, "SOLIS_CORE_MODBUS": { @@ -400,7 +401,6 @@ def brand_config(self): return self._brand_config def write_to_hass(self, entity_id, value, **kwargs): - self.log(f">>> {value}") try: value = float(value) except: @@ -410,7 +410,7 @@ def write_to_hass(self, entity_id, value, **kwargs): return self._host.write_and_poll_value(entity_id=entity_id, value=value, **kwargs) else: try: - return self._host.write_and_poll_time(entity_id=entity_id, time=value, **kwargs) + return self._host.write_and_poll_time(entity_id=entity_id, time=value, verbose=True, **kwargs) except: self.log( f"Unable to write value {value} to entity {entity_id}", @@ -474,7 +474,6 @@ def enable_timed_mode(self): self._enable_slot(direction="discharge") else: code = 35 - self.log(f">>> Setting energy control switch to {code} to enable timed mode") self._set_energy_control_switch(code) def _enable_slot(self, direction="charge"): @@ -483,7 +482,6 @@ def _enable_slot(self, direction="charge"): if cfg in self._brand_config and entity_id is not None: try: self._host.call_service("switch/turn_on", entity_id=entity_id) - self.log(f">>> Switch {entity_id} turned ON") except: self.log(f"Failed to turn on switch {entity_id}", level="WARNING") else: @@ -568,13 +566,19 @@ def _control_charge_discharge(self, direction, enable, **kwargs): else: target_soc = self.get_config("maximum_dod_percent") - self.log(f">>> direction: {direction}") - self.log(f">>> times: {times}") - self.log(f">>> current: {current}") + battery_current_limit = self.get_config("battery_current_limit_amps") + if battery_current_limit < current: + self.log( + f"battery_current_limit_amps of {battery_current_limit} is less than current of {current}A required by charging plan." + ) + self.log(f"Reducing inverter charge current to {battery_current_limit}A. ") + self.log("Check value of charger_power_watts in config.yaml if this is unexpected.") + self.log("") + + current = min(current, battery_current_limit) + changed = self._set_times(direction, **times) - self.log(f">>> changed: {changed}") changed = changed or self._set_current(direction, current) - self.log(f">>> changed: {changed}") if changed and self._requires_button_press: self.log("Something changed - need to press the appropriate Button") @@ -583,7 +587,6 @@ def _control_charge_discharge(self, direction, enable, **kwargs): else: entity_id = self.brand_config.get(f"id_timed_charge_discharge_button", None) - self.log(f">>> {entity_id}") if entity_id is not None: self._press_button(entity_id=entity_id) @@ -599,7 +602,6 @@ def hold_soc(self, enable, target_soc=0, **kwargs): 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._hold_soc = {"active": enable, "soc": target_soc} - self.log(f">>>>SOC: {target_soc}") if self._hmi_fb00: self._control_charge_discharge( @@ -698,6 +700,10 @@ def __init__(self, inverter_type, host): class SolisSolaxModbusInverter(SolisInverter): def __init__(self, inverter_type: str, host): super().__init__(inverter_type, host) + entity_id = self.brand_config["id_inverter_mode"] + entity_modes = self._host.get_state_retry(entity_id, attribute="options") + self._codes = INVERTER_DEFS[inverter_type]["codes"][self._hmi_fb00] + self._modes = {self._codes[code]: code for code in entity_modes} def _get_times_current(self, direction): # Required if the times are set as separate_hours and units @@ -795,9 +801,7 @@ def _write_modbus_register(self, register, value, cfg=None, tolerance=0, multipl self._host.call_service("modbus/write_register", **data) sleep(0.1) new_value = int(float(self.get_config(cfg))) / multiplier - self.log(f">>> current_value: {current_value/multiplier}") - self.log(f">>> value: {value}") - self.log(f">>> new_value: {new_value}") + written = new_value == value return changed, written @@ -851,5 +855,4 @@ def _write_modbus_register(self, register, value, cfg=None, tolerance=0, multipl if changed: data = {"register": register, "value": value} self._host.call_service("solarman/write_holding_register", **data) - self.log(">>> Writing {value} to inverter register {address} using Solarman") written = True