From 0ebd7dbdec2a08c8154876db07a59407134d3cd8 Mon Sep 17 00:00:00 2001 From: Rik Allen Date: Sat, 22 Aug 2026 09:18:31 +0100 Subject: [PATCH 1/2] feat(plan): split the Import p cell when the car's own rate diverges On Octopus Intelligent Go, a car's own low-rate dispatch allowance is capped independently of the house rate - once used up, the car falls back to the peak rate while the house keeps its real (possibly still cheap) rate for the same clock-time. The plan previously only ever showed the house rate, with no way to see that the car was actually paying more for the same slot. Adds car_charge_slot_rate() (kWh-weighted average rate across any car charging windows active in a slot) and splits the Import p cell - house on the left, car on the right, each independently coloured and with its own tooltip - whenever the two diverge. Server HTML and the client-side JS renderer both updated; editable/override mode is left untouched since overrides apply to the single household rate. Fixes #4646. Co-Authored-By: Claude Sonnet 5 --- apps/predbat/output.py | 41 +++++++++++++++---- apps/predbat/plan.py | 25 +++++++++++ apps/predbat/tests/test_octopus_slots.py | 4 ++ .../tests/test_plan_json_rate_adjust.py | 36 ++++++++++++++++ apps/predbat/web_helper.py | 10 +++++ 5 files changed, 109 insertions(+), 7 deletions(-) diff --git a/apps/predbat/output.py b/apps/predbat/output.py index b78302fe5..26c6b20c2 100644 --- a/apps/predbat/output.py +++ b/apps/predbat/output.py @@ -1069,6 +1069,16 @@ def publish_html_plan(self, pv_forecast_minute_step, pv_forecast_minute_step10, if self.rate_best_cost_threshold_export: export_cost_threshold = self.rate_best_cost_threshold_export + def import_rate_color(rate): + """Colour an import rate the same way as the plan's own Import p column (blue/green/yellow/red).""" + if rate <= 0: + return "#74C1FF" + elif rate <= import_cost_threshold: + return "#3AEE85" + elif rate > (import_cost_threshold * 1.5): + return "#F18261" + return "#FFFFAA" + raw_plan["import_cost_threshold"] = import_cost_threshold raw_plan["export_cost_threshold"] = export_cost_threshold raw_plan["reason_templates"] = REASON_TEMPLATES @@ -1310,12 +1320,7 @@ def publish_html_plan(self, pv_forecast_minute_step, pv_forecast_minute_step10, if plan_debug and load_forecast10 > 0.0: load_forecast += " (%s)" % (str(load_forecast10)) - if rate_value_import <= 0: # colour the import rate, blue for negative, then green, yellow and red - rate_color_import = "#74C1FF" - elif rate_value_import <= import_cost_threshold: - rate_color_import = "#3AEE85" - elif rate_value_import > (import_cost_threshold * 1.5): - rate_color_import = "#F18261" + rate_color_import = import_rate_color(rate_value_import) # blue for negative, then green, yellow and red if rate_value_export >= (1.5 * export_cost_threshold): rate_color_export = "#F18261" @@ -1501,16 +1506,26 @@ def publish_html_plan(self, pv_forecast_minute_step, pv_forecast_minute_step10, cost_color = "#FFFFFF" # Car charging? + car_rate = None if self.num_cars > 0: car_charging_kwh = self.car_charge_slot_kwh(minute_start, minute_end) car_total += car_charging_kwh if car_charging_kwh > 0.0: car_charging_str = str(car_charging_kwh) car_color = "FFFF00" + car_rate = self.car_charge_slot_rate(minute_start, minute_end) else: car_charging_str = "⚊" car_color = "#FFFFFF" + # The car's own rate can diverge from the general household rate once its IOG dispatch + # cap is used up for the day - the car falls back to the peak rate while the house keeps + # its real (possibly still cheap) rate for the same clock-time (batpred#4646). Split the + # Import p cell to show both when that happens. + rate_split = car_rate is not None and abs(car_rate - rate_value_import) > 0.01 + if rate_split: + car_rate_color = import_rate_color(car_rate) + # iBoost iboost_amount_str = "⚊" iboost_color = "#FFFFFF" @@ -1582,7 +1597,16 @@ def publish_html_plan(self, pv_forecast_minute_step, pv_forecast_minute_step10, # Table row html += '' html += "" + rate_start.strftime("%a %H:%M") + "" - html += "" + str(rate_str_import) + " " + if rate_split: + house_title = "House rate: {:.2f}{}/kWh".format(rate_value_import, self.currency_symbols[1]) + car_title = "Car rate: {:.2f}{}/kWh (IOG dispatch cap reached)".format(car_rate, self.currency_symbols[1]) + html += "' + html += '
' + html += '
' + str(rate_str_import) + "
" + html += '
' + "{:.2f}".format(car_rate) + "
" + html += "
" + else: + html += "" + str(rate_str_import) + " " html += "" + str(rate_str_export) + " " if start_span: if split: # for slots that are both charging and exporting, just output the (split cell) state @@ -1689,6 +1713,9 @@ def publish_html_plan(self, pv_forecast_minute_step, pv_forecast_minute_step10, if self.num_cars > 0: json_row["car_charging"] = car_charging_kwh json_row["car_color"] = car_color + json_row["car_rate"] = car_rate + json_row["car_rate_color"] = car_rate_color if rate_split else None + json_row["rate_split"] = rate_split if self.iboost_enable: json_row["iboost"] = iboost_amount json_row["iboost_change"] = iboost_change diff --git a/apps/predbat/plan.py b/apps/predbat/plan.py index e4ab41065..bc9dc276e 100644 --- a/apps/predbat/plan.py +++ b/apps/predbat/plan.py @@ -5256,6 +5256,31 @@ def car_charge_slot_kwh(self, minute_start, minute_end): car_charging_kwh = dp2(car_charging_kwh) return car_charging_kwh + def car_charge_slot_rate(self, minute_start, minute_end): + """ + Work out the car's own effective import rate (p/kWh) for the given + self.plan_interval_minutes-minute slot - the kWh-weighted average across any + car_charging_slots windows active in it. This can differ from the general household + import rate once a car's IOG dispatch cap is exhausted for the day (batpred#4646). + Returns None if no car charging in this slot. + """ + total_kwh = 0.0 + total_cost = 0.0 + if self.num_cars > 0: + for car_n in range(self.num_cars): + for window in self.car_charging_slots[car_n]: + start = window["start"] + end = window["end"] + if start < minute_end and end > minute_start and end != start: + overlap_start = max(start, minute_start) + overlap_end = min(end, minute_end) + kwh = dp2(window["kwh"]) * (overlap_end - overlap_start) / (end - start) + total_kwh += kwh + total_cost += kwh * window.get("average", 0) + if total_kwh > 0.0001: + return dp2(total_cost / total_kwh) + return None + def hit_car_window(self, window_start, window_end, cache=None): """Does this window intersect a car charging window? diff --git a/apps/predbat/tests/test_octopus_slots.py b/apps/predbat/tests/test_octopus_slots.py index 145e89a01..15e07355c 100644 --- a/apps/predbat/tests/test_octopus_slots.py +++ b/apps/predbat/tests/test_octopus_slots.py @@ -80,6 +80,10 @@ def run_load_octopus_slots_tests(my_predbat): my_predbat.rate_max_base = 10 my_predbat.car_charging_rate = [5.0] my_predbat.args["octopus_slot_max"] = 12 + # load_octopus_slots() short-circuits to [] when car_n >= self.num_cars - set this explicitly + # rather than relying on whatever a previous test in the same run left num_cars as (a shared + # my_predbat instance persists across tests within a run). + my_predbat.num_cars = 1 # Created 8 slots in total in the next 16 hours soc = 2.0 diff --git a/apps/predbat/tests/test_plan_json_rate_adjust.py b/apps/predbat/tests/test_plan_json_rate_adjust.py index 9d700036f..4cbb0cd3a 100644 --- a/apps/predbat/tests/test_plan_json_rate_adjust.py +++ b/apps/predbat/tests/test_plan_json_rate_adjust.py @@ -140,6 +140,42 @@ def run_test_plan_json_rate_adjust(my_predbat): my_predbat.rate_import_replicated = {} my_predbat.rate_export_replicated = {} + # --- Test 3: car rate diverging from the house rate (batpred#4646) --- + print("Test plan JSON output with a car rate that diverges from the house rate") + my_predbat.num_cars = 1 + car_minute = my_predbat.minutes_now + my_predbat.car_charging_slots[0] = [{"start": car_minute, "end": car_minute + 30, "kwh": 3.0, "average": 28.0, "cost": 84.0, "soc": 0.0, "octopus": True}] + + html_plan, raw_plan = my_predbat.publish_html_plan(pv_step, pv_step, load_step, load_step, my_predbat.end_record, publish=False) + car_row = next((row for row in raw_plan["rows"] if row.get("slot_minute") == car_minute), None) + if car_row is None: + print("WARNING: Could not find row for car minute {} in plan output".format(car_minute)) + else: + if car_row.get("car_rate") != 28.0: + print("ERROR: Expected car_rate=28.0 got {}".format(car_row.get("car_rate"))) + failed = True + if car_row.get("rate_split") is not True: + print("ERROR: Expected rate_split=True when car rate (28.0) diverges from house rate (10.0), got {}".format(car_row.get("rate_split"))) + failed = True + if not car_row.get("car_rate_color"): + print("ERROR: Expected car_rate_color to be set when rate_split is True") + failed = True + if "House rate: 10.00" not in html_plan or "Car rate: 28.00" not in html_plan: + print("ERROR: Expected split-cell HTML with house and car rate tooltips, got:\n{}".format(html_plan)) + failed = True + + # Same car window, but priced the same as the house rate - must not split + my_predbat.car_charging_slots[0] = [{"start": car_minute, "end": car_minute + 30, "kwh": 3.0, "average": 10.0, "cost": 30.0, "soc": 0.0, "octopus": True}] + html_plan, raw_plan = my_predbat.publish_html_plan(pv_step, pv_step, load_step, load_step, my_predbat.end_record, publish=False) + car_row = next((row for row in raw_plan["rows"] if row.get("slot_minute") == car_minute), None) + if car_row is not None and car_row.get("rate_split") is not False: + print("ERROR: Expected rate_split=False when car rate matches house rate, got {}".format(car_row.get("rate_split"))) + failed = True + + # Clean up + my_predbat.num_cars = 0 + my_predbat.car_charging_slots[0] = [] + if not failed: print("All plan JSON rate adjust type tests passed") return failed diff --git a/apps/predbat/web_helper.py b/apps/predbat/web_helper.py index 7733d37de..1e5d43a19 100644 --- a/apps/predbat/web_helper.py +++ b/apps/predbat/web_helper.py @@ -6526,6 +6526,16 @@ def get_plan_renderer_js(): } if (editable) { html += renderRateCell(row.import_rate, row.rate_color_import, 'import', row.time, timeDisplay, overrides, importText, row.slot_minute); + } else if (row.rate_split) { + // Car's own rate has diverged from the house rate (IOG dispatch cap reached) - + // split the cell, house on the left, car on the right, each with its own tooltip. + const houseTitle = escapeAttr(`House rate: ${row.import_rate.toFixed(2)}${currencyMinor}/kWh`); + const carTitle = escapeAttr(`Car rate: ${row.car_rate.toFixed(2)}${currencyMinor}/kWh (IOG dispatch cap reached)`); + html += ``; + html += `
`; + html += `
${importText}
`; + html += `
${row.car_rate.toFixed(2)}
`; + html += `
`; } else { html += `${importText}`; } From efb0e354d74374936b78a63ac9cbca2c5d3ad9ac Mon Sep 17 00:00:00 2001 From: Rik Allen Date: Sat, 22 Aug 2026 12:43:50 +0100 Subject: [PATCH 2/2] fix: address Copilot review on #4647 - HTML-escape the split cell's title attributes (currency_symbols is user-configurable free text; unescaped it could break out of the attribute in the server-rendered plan - the client JS path already used escapeAttr()). - Reword the car tooltip from "IOG dispatch cap reached" to "differs from house rate" - any car window with its own average can diverge, not just an IOG cap (e.g. combined dynamic-rate windows), so the original wording asserted a cause the data doesn't actually confirm. - car_charge_slot_rate() now skips windows with no "average" key instead of treating them as 0p/kWh. Non-Octopus historical reconstruction (Yesterday view) appends car-energy-sensor slots with no rate at all - defaulting to 0 dragged the weighted average down and falsely flagged ordinary charging as diverging from house rate. Co-Authored-By: Claude Sonnet 5 --- apps/predbat/output.py | 5 ++-- apps/predbat/plan.py | 8 +++++- .../tests/test_plan_json_rate_adjust.py | 28 +++++++++++++++++++ apps/predbat/web_helper.py | 7 +++-- 4 files changed, 42 insertions(+), 6 deletions(-) diff --git a/apps/predbat/output.py b/apps/predbat/output.py index 26c6b20c2..9aad580b5 100644 --- a/apps/predbat/output.py +++ b/apps/predbat/output.py @@ -18,6 +18,7 @@ import math import copy +from html import escape as escape_html from datetime import datetime, timedelta from config import THIS_VERSION from const import TIME_FORMAT, PREDICT_STEP, EXPORT_LIMIT_FREEZE, EXPORT_LIMIT_IDLE, MINUTE_WATT @@ -1598,8 +1599,8 @@ def import_rate_color(rate): html += '' html += "" + rate_start.strftime("%a %H:%M") + "" if rate_split: - house_title = "House rate: {:.2f}{}/kWh".format(rate_value_import, self.currency_symbols[1]) - car_title = "Car rate: {:.2f}{}/kWh (IOG dispatch cap reached)".format(car_rate, self.currency_symbols[1]) + house_title = escape_html("House rate: {:.2f}{}/kWh".format(rate_value_import, self.currency_symbols[1]), quote=True) + car_title = escape_html("Car rate: {:.2f}{}/kWh (differs from house rate)".format(car_rate, self.currency_symbols[1]), quote=True) html += "' html += '
' html += '
' + str(rate_str_import) + "
" diff --git a/apps/predbat/plan.py b/apps/predbat/plan.py index bc9dc276e..db12953f4 100644 --- a/apps/predbat/plan.py +++ b/apps/predbat/plan.py @@ -5269,6 +5269,12 @@ def car_charge_slot_rate(self, minute_start, minute_end): if self.num_cars > 0: for car_n in range(self.num_cars): for window in self.car_charging_slots[car_n]: + if "average" not in window: + # Non-Octopus historical reconstruction (Yesterday view) appends slots from + # the car's own energy sensor with no rate at all - treating that as 0p/kWh + # would drag the weighted average down and falsely flag ordinary charging as + # diverging from the house rate. Skip rather than guess. + continue start = window["start"] end = window["end"] if start < minute_end and end > minute_start and end != start: @@ -5276,7 +5282,7 @@ def car_charge_slot_rate(self, minute_start, minute_end): overlap_end = min(end, minute_end) kwh = dp2(window["kwh"]) * (overlap_end - overlap_start) / (end - start) total_kwh += kwh - total_cost += kwh * window.get("average", 0) + total_cost += kwh * window["average"] if total_kwh > 0.0001: return dp2(total_cost / total_kwh) return None diff --git a/apps/predbat/tests/test_plan_json_rate_adjust.py b/apps/predbat/tests/test_plan_json_rate_adjust.py index 4cbb0cd3a..43504d39d 100644 --- a/apps/predbat/tests/test_plan_json_rate_adjust.py +++ b/apps/predbat/tests/test_plan_json_rate_adjust.py @@ -163,6 +163,24 @@ def run_test_plan_json_rate_adjust(my_predbat): if "House rate: 10.00" not in html_plan or "Car rate: 28.00" not in html_plan: print("ERROR: Expected split-cell HTML with house and car rate tooltips, got:\n{}".format(html_plan)) failed = True + if "differs from house rate" not in html_plan: + print("ERROR: Expected the car tooltip to use neutral 'differs from house rate' wording (not every divergence is an IOG cap), got:\n{}".format(html_plan)) + failed = True + + # currency_symbols is user-configurable free text - a value carrying a double-quote must not + # break out of the split cell's title="..." attribute in the server-rendered plan (batpred#4647 + # review). Checking for the specific escaped form within the title, not a blanket string search - + # currency_symbols is also embedded unescaped elsewhere on the page (e.g. the Cost cell text, + # pre-existing and out of scope for this fix), which would give a false pass/fail either way. + saved_currency_symbols = my_predbat.currency_symbols + breakout = '">' + my_predbat.currency_symbols = ["£", "p" + breakout] + html_plan, raw_plan = my_predbat.publish_html_plan(pv_step, pv_step, load_step, load_step, my_predbat.end_record, publish=False) + expected_escaped = "House rate: 10.00p"><script>alert(1)</script>/kWh" + if expected_escaped not in html_plan: + print("ERROR: expected the split-cell title to HTML-escape currency_symbols, wanted:\n{}\ngot:\n{}".format(expected_escaped, html_plan)) + failed = True + my_predbat.currency_symbols = saved_currency_symbols # Same car window, but priced the same as the house rate - must not split my_predbat.car_charging_slots[0] = [{"start": car_minute, "end": car_minute + 30, "kwh": 3.0, "average": 10.0, "cost": 30.0, "soc": 0.0, "octopus": True}] @@ -172,6 +190,16 @@ def run_test_plan_json_rate_adjust(my_predbat): print("ERROR: Expected rate_split=False when car rate matches house rate, got {}".format(car_row.get("rate_split"))) failed = True + # A window with no "average" key at all (non-Octopus historical reconstruction in + # calculate_yesterday() appends slots like this from the car's own energy sensor) must not be + # treated as a free/0p charge - that would drag the weighted average down and falsely flag + # ordinary charging as diverging from the house rate. + my_predbat.car_charging_slots[0] = [{"start": car_minute, "end": car_minute + 30, "kwh": 3.0, "octopus": False}] + rate = my_predbat.car_charge_slot_rate(car_minute, car_minute + 30) + if rate is not None: + print("ERROR: Expected car_charge_slot_rate to skip a window with no average key, got {}".format(rate)) + failed = True + # Clean up my_predbat.num_cars = 0 my_predbat.car_charging_slots[0] = [] diff --git a/apps/predbat/web_helper.py b/apps/predbat/web_helper.py index 1e5d43a19..a8e60858a 100644 --- a/apps/predbat/web_helper.py +++ b/apps/predbat/web_helper.py @@ -6527,10 +6527,11 @@ def get_plan_renderer_js(): if (editable) { html += renderRateCell(row.import_rate, row.rate_color_import, 'import', row.time, timeDisplay, overrides, importText, row.slot_minute); } else if (row.rate_split) { - // Car's own rate has diverged from the house rate (IOG dispatch cap reached) - - // split the cell, house on the left, car on the right, each with its own tooltip. + // Car's own rate has diverged from the house rate - not necessarily an IOG cap + // (any car window with its own average can diverge, e.g. combined dynamic-rate + // windows) - split the cell, house on the left, car on the right, own tooltip each. const houseTitle = escapeAttr(`House rate: ${row.import_rate.toFixed(2)}${currencyMinor}/kWh`); - const carTitle = escapeAttr(`Car rate: ${row.car_rate.toFixed(2)}${currencyMinor}/kWh (IOG dispatch cap reached)`); + const carTitle = escapeAttr(`Car rate: ${row.car_rate.toFixed(2)}${currencyMinor}/kWh (differs from house rate)`); html += ``; html += `
`; html += `
${importText}
`;