Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Tariff compare improvements #1965

Merged
merged 17 commits into from
Feb 5, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .cspell/custom-dictionary-workspace.txt
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ emszzzz
enctype
endt
Energi
Energidata
Energidataservice
energydataservice
evse
Expand Down
233 changes: 202 additions & 31 deletions apps/predbat/compare.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,23 @@
# This application maybe used for personal use only and not for commercial use
# -----------------------------------------------------------------------------

import re
import os
from datetime import datetime, timedelta
from config import TIME_FORMAT, TIME_FORMAT_OCTOPUS
from utils import str2time, minutes_to_time, dp1, dp2
import yaml
import copy


class Compare:
def __init__(self, my_predbat):
self.pb = my_predbat
self.log = self.pb.log
self.config_root = self.pb.config_root
self.dashboard_item = self.pb.dashboard_item
self.prefix = self.pb.prefix
self.comparisons = {}
self.load_yaml()

def fetch_config(self, tariff):
"""
Expand Down Expand Up @@ -45,6 +51,20 @@ def fetch_rates(self, tariff, rate_import_base, rate_export_base):
if "rates_import_octopus_url" in tariff:
# Fixed URL for rate import
pb.rate_import = pb.download_octopus_rates(tariff["rates_import_octopus_url"])
elif "metric_octopus_import" in tariff:
# Octopus import rates
entity_id = pb.resolve_arg("metric_octopus_import", tariff["metric_octopus_import"])
if entity_id:
pb.rate_import = pb.fetch_octopus_rates(entity_id, adjust_key="is_intelligent_adjusted")
else:
self.log("Warn: Compare tariff {} bad Octopus entity id {}".format(tariff.get("id", ""), entity_id))
elif "metric_energidataservice_import" in tariff:
# Octopus import rates
entity_id = pb.resolve_arg("metric_energidataservice_import", tariff["metric_energidataservice_import"])
if entity_id:
pb.rate_import = pb.fetch_energidataservice_rates(entity_id, adjust_key="is_intelligent_adjusted")
else:
self.log("Warn: Compare tariff {} bad Energidata entity id {}".format(tariff.get("id", ""), entity_id))
elif "rates_import" in tariff:
pb.rate_import = pb.basic_rates(tariff["rates_import"], "rates_import")
else:
Expand All @@ -53,6 +73,20 @@ def fetch_rates(self, tariff, rate_import_base, rate_export_base):
if "rates_export_octopus_url" in tariff:
# Fixed URL for rate export
pb.rate_export = pb.download_octopus_rates(tariff["rates_export_octopus_url"])
elif "metric_octopus_export" in tariff:
# Octopus export rates
entity_id = pb.resolve_arg("metric_octopus_export", tariff["metric_octopus_export"])
if entity_id:
pb.rate_export = pb.fetch_octopus_rates(entity_id)
else:
self.log("Warn: Compare tariff {} bad Octopus entity id {}".format(tariff.get("id", ""), entity_id))
elif "metric_energidataservice_export" in tariff:
# Octopus import rates
entity_id = pb.resolve_arg("metric_energidataservice_export", tariff["metric_energidataservice_export"])
if entity_id:
pb.rate_export = pb.fetch_energidataservice_rates(entity_id, adjust_key="is_intelligent_adjusted")
else:
self.log("Warn: Compare tariff {} bad Energidata entity id {}".format(tariff.get("id", ""), entity_id))
elif "rates_export" in tariff:
pb.rate_export = pb.basic_rates(tariff["rates_export"], "rates_export")
else:
Expand Down Expand Up @@ -92,6 +126,16 @@ def fetch_rates(self, tariff, rate_import_base, rate_export_base):
if pb.rate_low_threshold == 0 and highest >= pb.rate_min:
pb.rate_import_cost_threshold = highest

# Compare to see if rates changes
for minute in range(pb.minutes_now, pb.forecast_minutes + pb.minutes_now):
if dp2(pb.rate_import.get(minute, 0)) != dp2(rate_import_base.get(minute, 0)):
self.log("Compare rate import is different, minute {} changed from {} to {}".format(minute, rate_import_base.get(minute, 0), pb.rate_import.get(minute, 0)))
return False
if dp2(pb.rate_export.get(minute, 0)) != dp2(rate_export_base.get(minute, 0)):
self.log("Compare rate export is different, minute {} changed from {} to {}".format(minute, rate_export_base.get(minute, 0), pb.rate_export.get(minute, 0)))
return False
return True

def run_scenario(self, end_record):
my_predbat = self.pb

Expand All @@ -113,35 +157,44 @@ def run_scenario(self, end_record):
True,
end_record=end_record,
)
metric, battery_value = my_predbat.compute_metric(end_record, soc, soc10, cost, cost10, final_iboost, final_iboost10, battery_cycle, metric_keep, final_carbon_g, import_kwh_battery, import_kwh_house, export_kwh)
# Work out value of the battery at the start end end of the period to allow for the change in SOC
metric_start, battery_value_start = my_predbat.compute_metric(end_record, my_predbat.soc_kw, my_predbat.soc_kw, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0)
metric_end, battery_value_end = my_predbat.compute_metric(end_record, soc, soc10, cost, cost10, final_iboost, final_iboost10, 0, 0, 0, 0, 0, 0)

# Subtract the start metric from the end metric to avoid credit for the current battery level re-based on the new tariff
metric = metric_end - metric_start
html = my_predbat.publish_html_plan(pv_step, pv10_step, load_step, load10_step, end_record, publish=False)

result_data = {
"cost": cost,
"cost10": cost10,
"import": import_kwh_battery + import_kwh_house,
"import10": import_kwh_battery10 + import_kwh_house10,
"export": export_kwh,
"export10": export_kwh10,
"soc": soc,
"soc10": soc10,
"soc_min": soc_min,
"soc_min10": soc_min10,
"battery_cycle": battery_cycle,
"battery_cycle10": battery_cycle10,
"metric": metric,
"metric_keep": metric_keep,
"metric_keep10": metric_keep10,
"final_iboost": final_iboost,
"final_iboost10": final_iboost10,
"final_carbon_g": final_carbon_g,
"final_carbon_g10": final_carbon_g10,
"cost": dp2(cost),
"cost10": dp2(cost10),
"import_kwh": dp2(import_kwh_battery + import_kwh_house),
"import_kwh10": dp2(import_kwh_battery10 + import_kwh_house10),
"export_kwh": dp2(export_kwh),
"export_kwh10": dp2(export_kwh10),
"soc": dp2(soc),
"soc10": dp2(soc10),
"soc_min": dp2(soc_min),
"soc_min10": dp2(soc_min10),
"battery_cycle": dp2(battery_cycle),
"battery_cycle10": dp2(battery_cycle10),
"metric": dp2(metric),
"metric_keep": dp2(metric_keep),
"metric_keep10": dp2(metric_keep10),
"final_iboost": dp2(final_iboost),
"final_iboost10": dp2(final_iboost10),
"final_carbon_g": dp2(final_carbon_g),
"final_carbon_g10": dp2(final_carbon_g10),
"battery_value_start": dp2(battery_value_start),
"battery_value_end": dp2(battery_value_end),
"metric_real": dp2(metric_end),
"end_record": end_record,
}
for item in result_data:
result_data[item] = dp2(result_data[item])
result_data["html"] = html
result_data["date"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
result_data["best"] = False

return result_data

Expand All @@ -150,43 +203,152 @@ def run_single(self, tariff, rate_import_base, rate_export_base, end_record, deb
Compare a single energy tariff with the current settings and report results
"""
name = tariff.get("name", None)
tariff_id = tariff.get("id", "")
if not name:
self.log("Warn: Compare tariff name not found")
return None
self.log("Compare Tariff: {}".format(name))
self.fetch_config(tariff)

if fetch_sensor:
self.pb.fetch_sensor_data()
self.fetch_rates(tariff, rate_import_base, rate_export_base)

# Fetch rates
try:
existing_tariff = self.fetch_rates(tariff, rate_import_base, rate_export_base)
except ValueError as e:
self.log("Warn fetching rates during comparison of tariff {}: {}".format(tariff, e))
return {}

self.log("Running scenario for tariff: {}".format(name))
result_data = self.run_scenario(end_record)
result_data["existing_tariff"] = existing_tariff
self.log("Scenario complete for tariff: {} cost {} metric {}".format(name, result_data["cost"], result_data["metric"]))
if debug:
with open("compare_{}.html".format(name), "w") as f:
with open("compare_{}.html".format(tariff_id), "w") as f:
f.write(result_data["html"])
return result_data

def select_best(self, compare_list, results):
"""
Recommend the best tariff
"""
best_selected = ""
best_metric = 9999999999

for compare in compare_list:
tariff_id = compare.get("id", "")
result = results.get(tariff_id, {})
if result:
metric = result.get("metric", best_metric)
if metric < best_metric:
best_metric = metric
best_selected = tariff_id

for result_id in results:
results[result_id]["best"] = result_id == best_selected

self.log("Compare, best tariff: {} metric {}".format(best_selected, best_metric))

def get_comparison(self, tariff_id):
"""
Get comparisons
"""
return self.comparisons.get(tariff_id, {})

def load_yaml(self):
"""
Load comparisons from yaml
"""
filepath = self.config_root + "/comparisons.yaml"
if os.path.exists(filepath):
with open(filepath, "r") as f:
try:
data = yaml.safe_load(f)
if data:
self.comparisons = data.get("comparisons", {})
except yaml.YAMLError as exc:
self.log("Error loading comparisons: {}".format(exc))

if self.comparisons:
compare_list = self.pb.get_arg("compare_list", [])
self.select_best(compare_list, self.comparisons)
self.publish_data()

def save_yaml(self):
"""
Save comparisons to yaml
"""
filepath = self.config_root + "/comparisons.yaml"
save_data = {}
save_data["comparisons"] = self.comparisons

with open(filepath, "w") as f:
f.write(yaml.dump(save_data, default_flow_style=False))

def publish_data(self):
"""
Publish comparison data to HA
"""
for tariff_id in self.comparisons:
result = self.get_comparison(tariff_id)

if result:
cost = result.get("cost", 0)
name = result.get("name", "")
attributes = {
"friendly_name": "Compare " + name,
"state_class": "measurement",
"unit_of_measurement": "p",
"icon": "mdi::compare-horizontal",
}
for item in result:
value = result[item]
if item != "html":
attributes[item] = value

self.dashboard_item(
self.prefix + ".compare_tariff_" + tariff_id,
state=cost,
attributes=attributes,
)

def run_all(self, debug=False, fetch_sensor=True):
"""
Compare a comparison in prices across multiple energy tariffs and report results
take care not to destroy the state of the system for the primary settings
"""
compare = self.pb.get_arg("compare", [])
if not compare:
compare_list = self.pb.get_arg("compare_list", [])
if not compare_list:
return

results = {}
results = self.comparisons

my_predbat = self.pb

save_forecast_plan_hours = my_predbat.forecast_plan_hours
save_forecast_minutes = my_predbat.forecast_minutes
save_forecast_days = my_predbat.forecast_days
save_manual_charge_times = my_predbat.manual_charge_times
save_manual_export_times = my_predbat.manual_export_times
save_manual_freeze_charge_times = my_predbat.manual_freeze_charge_times
save_manual_freeze_export_times = my_predbat.manual_freeze_export_times
save_manual_demand_times = my_predbat.manual_demand_times
save_manual_all_times = my_predbat.manual_all_times

# Change to a fixed 48 hour plan
my_predbat.forecast_plan_hours = 48
my_predbat.forecast_minutes = my_predbat.forecast_plan_hours * 60
my_predbat.forecast_days = my_predbat.forecast_plan_hours / 24

# Clear manual times to avoid users overrides
my_predbat.manual_charge_times = []
my_predbat.manual_export_times = []
my_predbat.manual_freeze_charge_times = []
my_predbat.manual_freeze_export_times = []
my_predbat.manual_demand_times = []
my_predbat.manual_all_times = []

# Final reports, cut end_record back to 24 hours to ignore the dump at end of day
end_record = int((my_predbat.minutes_now + 24 * 60 + 29) / 30) * 30 - my_predbat.minutes_now

Expand All @@ -195,14 +357,23 @@ def run_all(self, debug=False, fetch_sensor=True):
rate_export_base = copy.deepcopy(self.pb.rate_export)

self.log("Starting comparison of tariffs")
for tariff in compare:
result_data = self.run_single(tariff, rate_import_base, rate_export_base, end_record, debug=debug, fetch_sensor=fetch_sensor)
results[tariff["name"]] = result_data
self.pb.comparisons = results

self.pb.comparisons_date = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
for tariff in compare_list:
result_data = self.run_single(tariff, rate_import_base, rate_export_base, end_record, debug=debug, fetch_sensor=fetch_sensor)
results[tariff["id"]] = result_data
# Save and update comparisons as we go so it is updated in HA
self.select_best(compare_list, results)
self.comparisons = results
self.save_yaml()
self.publish_data()

# Restore original settings
my_predbat.forecast_plan_hours = save_forecast_plan_hours
my_predbat.forecast_minutes = save_forecast_minutes
my_predbat.forecast_days = save_forecast_days
my_predbat.manual_charge_times = save_manual_charge_times
my_predbat.manual_export_times = save_manual_export_times
my_predbat.manual_freeze_charge_times = save_manual_freeze_charge_times
my_predbat.manual_freeze_export_times = save_manual_freeze_export_times
my_predbat.manual_demand_times = save_manual_demand_times
my_predbat.manual_all_times = save_manual_all_times
2 changes: 1 addition & 1 deletion apps/predbat/fetch.py
Original file line number Diff line number Diff line change
Expand Up @@ -1456,7 +1456,7 @@ def basic_rates(self, info, rtype, prev=None, rate_replicate={}):
day_of_week_midnight = self.midnight.weekday()

# Store rates against range
if end_minutes >= (-24 * 60) and start_minutes < max_minute:
if end_minutes >= (-48 * 60) and start_minutes < max_minute:
for minute in range(start_minutes, end_minutes):
minute_mod = minute % max_minute
if (not date) or (minute >= (-24 * 60) and minute < max_minute):
Expand Down
11 changes: 5 additions & 6 deletions apps/predbat/predbat.py
Original file line number Diff line number Diff line change
Expand Up @@ -268,8 +268,7 @@ def reset(self):
"""
reset_prediction_globals()
self.CONFIG_ITEMS = copy.deepcopy(CONFIG_ITEMS)
self.comparisons = {}
self.comparisons_date = None
self.comparison = None
self.compare_tariffs = False
self.predheat = None
self.predbat_mode = "Monitor"
Expand Down Expand Up @@ -768,10 +767,9 @@ def update_pred(self, scheduled=True):
self.expose_config("active", False)
self.save_current_config()

# Compare tariffs
if self.compare_tariffs:
compare = Compare(self)
compare.run_all()
# Compare tariffs either when triggered or daily at midnight
if ((scheduled and self.minutes_now < RUN_EVERY) or self.compare_tariffs) and self.comparison:
self.comparison.run_all()
self.compare_tariffs = False

async def async_download_predbat_version(self, version):
Expand Down Expand Up @@ -865,6 +863,7 @@ def initialize(self):
self.ha_interface.update_states()
self.auto_config()
self.load_user_config(quiet=False, register=True)
self.comparison = Compare(self)
except Exception as e:
self.log("Error: Exception raised {}".format(e))
self.log("Error: " + traceback.format_exc())
Expand Down
Loading