From 37f08b5b6c09ae387b6cf63d53c0579d6f48b6cf Mon Sep 17 00:00:00 2001 From: Trefor Southwell <48591903+springfall2008@users.noreply.github.com> Date: Sun, 28 Jan 2024 20:08:18 +0000 Subject: [PATCH 1/3] Find charge curve code --- apps/predbat/predbat.py | 147 +++++++++++++++++++++++++++++++++++++--- 1 file changed, 139 insertions(+), 8 deletions(-) diff --git a/apps/predbat/predbat.py b/apps/predbat/predbat.py index 3d2d8055f..8f6d487cf 100644 --- a/apps/predbat/predbat.py +++ b/apps/predbat/predbat.py @@ -980,6 +980,73 @@ def __init__(self, base, id=0, quiet=False): for y in ["start", "end"]: self.base.args[f"{x}_{y}_time"] = self.create_entity(f"{x}_{y}_time", "23:59:00") + def find_charge_curve(self): + """ + Find expected charge curve + """ + + soc_kwh_sensor = self.base.get_arg("soc_kw", indirect=False, index=self.id) + charge_rate_sensor = self.base.get_arg("charge_rate", indirect=False, index=self.id) + predbat_status_sensor = "predbat.status" + battery_power_sensor = self.base.get_arg("battery_power", indirect=False, index=self.id) + battery_power_sensor = battery_power_sensor.replace('number.', 'sensor.') # Workaround as old template had number. + final_curve = {} + + max_power = int(self.battery_rate_max_charge * 1000.0 * 60.0) + if soc_kwh_sensor and charge_rate_sensor: + self.log("Find charge curve with sensors {} and {} and {} and {}".format(soc_kwh_sensor, charge_rate_sensor, predbat_status_sensor, battery_power_sensor)) + soc_kwh_data = self.base.get_history(entity_id=soc_kwh_sensor, days=self.base.max_days_previous) + charge_rate_data = self.base.get_history(entity_id=charge_rate_sensor, days=self.base.max_days_previous) + predbat_status_data = self.base.get_history(entity_id=predbat_status_sensor, days=self.base.max_days_previous) + battery_power_data = self.base.get_history(entity_id=battery_power_sensor, days=self.base.max_days_previous) + + if soc_kwh_data and charge_rate_data and charge_rate_data and battery_power_data: + soc_kwh = self.base.minute_data(soc_kwh_data[0], self.base.max_days_previous, self.base.now_utc, "state", "last_updated", backwards=True, clean_increment=False, smoothing=False, divide_by=1.0, scale=1.0) + charge_rate = self.base.minute_data(charge_rate_data[0], self.base.max_days_previous, self.base.now_utc, "state", "last_updated", backwards=True, clean_increment=False, smoothing=False, divide_by=1.0, scale=1.0) + predbat_status = self.base.minute_data_state(predbat_status_data[0], self.base.max_days_previous, self.base.now_utc, "state", "last_updated") + battery_power = self.base.minute_data(battery_power_data[0], self.base.max_days_previous, self.base.now_utc, "state", "last_updated", backwards=True, clean_increment=False, smoothing=False, divide_by=1.0, scale=1.0) + min_len = min(len(soc_kwh), len(charge_rate), len(predbat_status), len(battery_power)) + self.log("Find charge curve has {} days of data, max days {}".format(min_len/60/24.0, self.base.max_days_previous)) + + soc_percent = {} + for minute in range(0, min_len): + soc_percent[minute] = int((soc_kwh[minute] / self.soc_max)*100.0 + 0.5) + + # Find 100% end points + data_point = 99 + for minute in range(0, min_len): + if soc_percent.get(minute, 0) == data_point and predbat_status[minute] == 'Charging' and charge_rate[minute] == max_power and battery_power[minute] <= 0: + found = False + total_power = 0 + for target_minute in range(minute + 1, min_len): + if predbat_status[target_minute] != 'Charging' or charge_rate[minute] != max_power or battery_power[minute] > 0: + break + total_power += abs(battery_power[minute]) + if soc_percent.get(target_minute, 0) == (data_point - 1): + time_diff = target_minute - minute + from_soc = soc_kwh[minute] + to_soc = soc_kwh[target_minute] + soc_charged = from_soc - to_soc + average_power = total_power / time_diff + charge_curve = round(min(average_power / max_power / self.base.battery_loss, 1.0), 2) + self.log("Charge Curve Percent: {} at {} took {} minutes charged {} curve {} average_power {}".format(data_point, self.base.time_abs_str(self.base.minutes_now - minute), time_diff, round(soc_charged, 2), charge_curve, average_power)) + final_curve[data_point] = charge_curve + if data_point == 99: + final_curve[100] = charge_curve + found = True + break + if found: + data_point -= 1 + if data_point < 85: + break + self.log("Charge curve can be entered into apps.yaml: {}".format(final_curve)) + else: + self.log("Note: Can not find battery charge curve, one of the required settings for soc_kw and charge_rate do not have history, check apps.yaml") + else: + self.log("Note: Can not find battery charge curve, one of the required settings for soc_kw and charge_rate are missing from apps.yaml") + + + def create_entity(self, entity_name, value, uom=None, device_class="None"): """ Create dummy entities required by non GE inverters to mimic GE behaviour @@ -2974,6 +3041,69 @@ def minute_data_load(self, now_utc, entity_name, max_days_previous): age_days = 0 return load_minutes, age_days + def minute_data_state(self, history, days, now, state_key, last_updated_key): + """ + Get historical data for state (e.g. predbat status) + """ + mdata = {} + prev_last_updated_time = None + last_state = "unknown" + newest_state = 0 + last_state = 0 + newest_age = 999999 + + if not history: + self.log("Warning, empty history passed to minute_data_state, ignoring (check your settings)...") + return mdata + + # Process history + for item in history: + # Ignore data without correct keys + if state_key not in item: + continue + if last_updated_key not in item: + continue + + # Unavailable or bad values + if item[state_key] == "unavailable" or item[state_key] == "unknown": + continue + + state = item[state_key] + last_updated_time = self.str2time(item[last_updated_key]) + + # Update prev to the first if not set + if not prev_last_updated_time: + prev_last_updated_time = last_updated_time + last_state = state + + timed = now - last_updated_time + timed_to = now - prev_last_updated_time + + minutes_to = int(timed_to.seconds / 60) + int(timed_to.days * 60 * 24) + minutes = int(timed.seconds / 60) + int(timed.days * 60 * 24) + + minute = minutes + while minute < minutes_to: + mdata[minute] = last_state + minute += 1 + + # Store previous state + prev_last_updated_time = last_updated_time + last_state = state + + if minutes < newest_age: + newest_age = minutes + newest_state = state + + state = newest_state + for minute in range(0, 60 * 24 * days): + rindex = 60 * 24 * days - minute - 1 + state = mdata.get(rindex, state) + mdata[rindex] = state + minute += 1 + + return mdata + def minute_data( self, history, @@ -3720,7 +3850,7 @@ def get_charge_rate_curve(self, soc, charge_rate_setting): soc_percent = self.calc_percent_limit(soc) max_charge_rate = self.battery_rate_max_charge * self.battery_charge_power_curve.get(soc_percent, 1.0) * self.battery_rate_max_scaling return max(min(charge_rate_setting, max_charge_rate), self.battery_rate_min) - + def run_prediction(self, charge_limit, charge_window, discharge_window, discharge_limits, load_minutes_step, pv_forecast_minute_step, end_record, save=None, step=PREDICT_STEP): """ Run a prediction scenario given a charge limit, options to save the results or not to HA entity @@ -6634,6 +6764,7 @@ def reset(self): self.future_energy_rates_import = {} self.future_energy_rates_export = {} self.load_scaling_dynamic = {} + self.computed_charge_curve = False def optimise_charge_limit_price( self, @@ -8580,11 +8711,7 @@ def find_charge_rate(self, minutes_now, soc, window, target_soc, max_rate, quiet break rate_w -= 125.0 if not quiet: - self.log( - "Find charge rate now {} soc {} window {} target_soc {} max_rate {} min_rate {} returns {}".format( - minutes_now, soc, window, target_soc, int(max_rate * 60.0 * 1000.0), int(min_rate * 60.0 * 1000.0), int(best_rate * 60.0 * 1000.0) - ) - ) + self.log("Find charge rate now {} soc {} window {} target_soc {} max_rate {} min_rate {} returns {}".format(minutes_now, soc, window, target_soc, int(max_rate * 60.0 * 1000.0), int(min_rate * 60.0 * 1000.0), int(best_rate * 60.0 * 1000.0))) return best_rate else: return max_rate @@ -9752,6 +9879,10 @@ def fetch_inverter_data(self): inverter = Inverter(self, id) inverter.update_status(self.minutes_now) + if id == 0 and not self.computed_charge_curve: + inverter.find_charge_curve() + self.computed_charge_curve = True + # As the inverters will run in lockstep, we will initially look at the programming of the first enabled one for the current window setting if not found_first: found_first = True @@ -10954,7 +11085,7 @@ def initialize(self): if run_every_balance > 0: self.log("Balance inverters will run every {} seconds (if enabled)".format(run_every_balance)) seconds_offset_balance = seconds_now % run_every_balance - seconds_next_balance = seconds_now + (run_every_balance - seconds_offset_balance) + 15 # Offset to start after Predbat update task + seconds_next_balance = seconds_now + (run_every_balance - seconds_offset_balance) + 5 # Offset to start after Predbat update task next_time_balance = midnight + timedelta(seconds=seconds_next_balance) self.run_every(self.run_time_loop_balance, next_time_balance, run_every_balance, random_start=0, random_end=0) @@ -11012,4 +11143,4 @@ def run_time_loop_balance(self, cb_args): except Exception as e: self.log("ERROR: Exception raised {}".format(e)) self.record_status("ERROR: Exception raised {}".format(e)) - raise + raise \ No newline at end of file From 65f9d92a11a77c01ed485934b75db690b4e1f19b Mon Sep 17 00:00:00 2001 From: "pre-commit-ci-lite[bot]" <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Date: Sun, 28 Jan 2024 20:09:56 +0000 Subject: [PATCH 2/3] [pre-commit.ci lite] apply automatic fixes --- apps/predbat/predbat.py | 73 +++++++++++++++++++++++++++++++---------- 1 file changed, 56 insertions(+), 17 deletions(-) diff --git a/apps/predbat/predbat.py b/apps/predbat/predbat.py index 8f6d487cf..1ba6ed05d 100644 --- a/apps/predbat/predbat.py +++ b/apps/predbat/predbat.py @@ -989,7 +989,7 @@ def find_charge_curve(self): charge_rate_sensor = self.base.get_arg("charge_rate", indirect=False, index=self.id) predbat_status_sensor = "predbat.status" battery_power_sensor = self.base.get_arg("battery_power", indirect=False, index=self.id) - battery_power_sensor = battery_power_sensor.replace('number.', 'sensor.') # Workaround as old template had number. + battery_power_sensor = battery_power_sensor.replace("number.", "sensor.") # Workaround as old template had number. final_curve = {} max_power = int(self.battery_rate_max_charge * 1000.0 * 60.0) @@ -1001,25 +1001,58 @@ def find_charge_curve(self): battery_power_data = self.base.get_history(entity_id=battery_power_sensor, days=self.base.max_days_previous) if soc_kwh_data and charge_rate_data and charge_rate_data and battery_power_data: - soc_kwh = self.base.minute_data(soc_kwh_data[0], self.base.max_days_previous, self.base.now_utc, "state", "last_updated", backwards=True, clean_increment=False, smoothing=False, divide_by=1.0, scale=1.0) - charge_rate = self.base.minute_data(charge_rate_data[0], self.base.max_days_previous, self.base.now_utc, "state", "last_updated", backwards=True, clean_increment=False, smoothing=False, divide_by=1.0, scale=1.0) + soc_kwh = self.base.minute_data( + soc_kwh_data[0], + self.base.max_days_previous, + self.base.now_utc, + "state", + "last_updated", + backwards=True, + clean_increment=False, + smoothing=False, + divide_by=1.0, + scale=1.0, + ) + charge_rate = self.base.minute_data( + charge_rate_data[0], + self.base.max_days_previous, + self.base.now_utc, + "state", + "last_updated", + backwards=True, + clean_increment=False, + smoothing=False, + divide_by=1.0, + scale=1.0, + ) predbat_status = self.base.minute_data_state(predbat_status_data[0], self.base.max_days_previous, self.base.now_utc, "state", "last_updated") - battery_power = self.base.minute_data(battery_power_data[0], self.base.max_days_previous, self.base.now_utc, "state", "last_updated", backwards=True, clean_increment=False, smoothing=False, divide_by=1.0, scale=1.0) + battery_power = self.base.minute_data( + battery_power_data[0], + self.base.max_days_previous, + self.base.now_utc, + "state", + "last_updated", + backwards=True, + clean_increment=False, + smoothing=False, + divide_by=1.0, + scale=1.0, + ) min_len = min(len(soc_kwh), len(charge_rate), len(predbat_status), len(battery_power)) - self.log("Find charge curve has {} days of data, max days {}".format(min_len/60/24.0, self.base.max_days_previous)) + self.log("Find charge curve has {} days of data, max days {}".format(min_len / 60 / 24.0, self.base.max_days_previous)) soc_percent = {} for minute in range(0, min_len): - soc_percent[minute] = int((soc_kwh[minute] / self.soc_max)*100.0 + 0.5) + soc_percent[minute] = int((soc_kwh[minute] / self.soc_max) * 100.0 + 0.5) # Find 100% end points data_point = 99 for minute in range(0, min_len): - if soc_percent.get(minute, 0) == data_point and predbat_status[minute] == 'Charging' and charge_rate[minute] == max_power and battery_power[minute] <= 0: + if soc_percent.get(minute, 0) == data_point and predbat_status[minute] == "Charging" and charge_rate[minute] == max_power and battery_power[minute] <= 0: found = False total_power = 0 for target_minute in range(minute + 1, min_len): - if predbat_status[target_minute] != 'Charging' or charge_rate[minute] != max_power or battery_power[minute] > 0: + if predbat_status[target_minute] != "Charging" or charge_rate[minute] != max_power or battery_power[minute] > 0: break total_power += abs(battery_power[minute]) if soc_percent.get(target_minute, 0) == (data_point - 1): @@ -1029,7 +1062,11 @@ def find_charge_curve(self): soc_charged = from_soc - to_soc average_power = total_power / time_diff charge_curve = round(min(average_power / max_power / self.base.battery_loss, 1.0), 2) - self.log("Charge Curve Percent: {} at {} took {} minutes charged {} curve {} average_power {}".format(data_point, self.base.time_abs_str(self.base.minutes_now - minute), time_diff, round(soc_charged, 2), charge_curve, average_power)) + self.log( + "Charge Curve Percent: {} at {} took {} minutes charged {} curve {} average_power {}".format( + data_point, self.base.time_abs_str(self.base.minutes_now - minute), time_diff, round(soc_charged, 2), charge_curve, average_power + ) + ) final_curve[data_point] = charge_curve if data_point == 99: final_curve[100] = charge_curve @@ -1045,8 +1082,6 @@ def find_charge_curve(self): else: self.log("Note: Can not find battery charge curve, one of the required settings for soc_kw and charge_rate are missing from apps.yaml") - - def create_entity(self, entity_name, value, uom=None, device_class="None"): """ Create dummy entities required by non GE inverters to mimic GE behaviour @@ -3055,7 +3090,7 @@ def minute_data_state(self, history, days, now, state_key, last_updated_key): if not history: self.log("Warning, empty history passed to minute_data_state, ignoring (check your settings)...") return mdata - + # Process history for item in history: # Ignore data without correct keys @@ -3075,10 +3110,10 @@ def minute_data_state(self, history, days, now, state_key, last_updated_key): if not prev_last_updated_time: prev_last_updated_time = last_updated_time last_state = state - + timed = now - last_updated_time timed_to = now - prev_last_updated_time - + minutes_to = int(timed_to.seconds / 60) + int(timed_to.days * 60 * 24) minutes = int(timed.seconds / 60) + int(timed.days * 60 * 24) @@ -3850,7 +3885,7 @@ def get_charge_rate_curve(self, soc, charge_rate_setting): soc_percent = self.calc_percent_limit(soc) max_charge_rate = self.battery_rate_max_charge * self.battery_charge_power_curve.get(soc_percent, 1.0) * self.battery_rate_max_scaling return max(min(charge_rate_setting, max_charge_rate), self.battery_rate_min) - + def run_prediction(self, charge_limit, charge_window, discharge_window, discharge_limits, load_minutes_step, pv_forecast_minute_step, end_record, save=None, step=PREDICT_STEP): """ Run a prediction scenario given a charge limit, options to save the results or not to HA entity @@ -8711,7 +8746,11 @@ def find_charge_rate(self, minutes_now, soc, window, target_soc, max_rate, quiet break rate_w -= 125.0 if not quiet: - self.log("Find charge rate now {} soc {} window {} target_soc {} max_rate {} min_rate {} returns {}".format(minutes_now, soc, window, target_soc, int(max_rate * 60.0 * 1000.0), int(min_rate * 60.0 * 1000.0), int(best_rate * 60.0 * 1000.0))) + self.log( + "Find charge rate now {} soc {} window {} target_soc {} max_rate {} min_rate {} returns {}".format( + minutes_now, soc, window, target_soc, int(max_rate * 60.0 * 1000.0), int(min_rate * 60.0 * 1000.0), int(best_rate * 60.0 * 1000.0) + ) + ) return best_rate else: return max_rate @@ -11143,4 +11182,4 @@ def run_time_loop_balance(self, cb_args): except Exception as e: self.log("ERROR: Exception raised {}".format(e)) self.record_status("ERROR: Exception raised {}".format(e)) - raise \ No newline at end of file + raise From 7c43437cb952dabeda23d1b68cc91e07b72a1cb4 Mon Sep 17 00:00:00 2001 From: Trefor Southwell <48591903+springfall2008@users.noreply.github.com> Date: Sun, 28 Jan 2024 20:10:51 +0000 Subject: [PATCH 3/3] Fix typo --- apps/predbat/predbat.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/predbat/predbat.py b/apps/predbat/predbat.py index 1ba6ed05d..44abf18a4 100644 --- a/apps/predbat/predbat.py +++ b/apps/predbat/predbat.py @@ -11124,7 +11124,7 @@ def initialize(self): if run_every_balance > 0: self.log("Balance inverters will run every {} seconds (if enabled)".format(run_every_balance)) seconds_offset_balance = seconds_now % run_every_balance - seconds_next_balance = seconds_now + (run_every_balance - seconds_offset_balance) + 5 # Offset to start after Predbat update task + seconds_next_balance = seconds_now + (run_every_balance - seconds_offset_balance) + 15 # Offset to start after Predbat update task next_time_balance = midnight + timedelta(seconds=seconds_next_balance) self.run_every(self.run_time_loop_balance, next_time_balance, run_every_balance, random_start=0, random_end=0)