Skip to content

Commit 30e364a

Browse files
Merge pull request #4647 from springfall2008/feat/octopus-car-rate-split
feat(plan): split the Import p cell when the car's own rate diverges
2 parents 91705ab + efb0e35 commit 30e364a

5 files changed

Lines changed: 145 additions & 7 deletions

File tree

apps/predbat/output.py

Lines changed: 35 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818

1919
import math
2020
import copy
21+
from html import escape as escape_html
2122
from datetime import datetime, timedelta
2223
from config import THIS_VERSION
2324
from const import TIME_FORMAT, PREDICT_STEP, EXPORT_LIMIT_FREEZE, EXPORT_LIMIT_IDLE, MINUTE_WATT
@@ -1069,6 +1070,16 @@ def publish_html_plan(self, pv_forecast_minute_step, pv_forecast_minute_step10,
10691070
if self.rate_best_cost_threshold_export:
10701071
export_cost_threshold = self.rate_best_cost_threshold_export
10711072

1073+
def import_rate_color(rate):
1074+
"""Colour an import rate the same way as the plan's own Import p column (blue/green/yellow/red)."""
1075+
if rate <= 0:
1076+
return "#74C1FF"
1077+
elif rate <= import_cost_threshold:
1078+
return "#3AEE85"
1079+
elif rate > (import_cost_threshold * 1.5):
1080+
return "#F18261"
1081+
return "#FFFFAA"
1082+
10721083
raw_plan["import_cost_threshold"] = import_cost_threshold
10731084
raw_plan["export_cost_threshold"] = export_cost_threshold
10741085
raw_plan["reason_templates"] = REASON_TEMPLATES
@@ -1310,12 +1321,7 @@ def publish_html_plan(self, pv_forecast_minute_step, pv_forecast_minute_step10,
13101321
if plan_debug and load_forecast10 > 0.0:
13111322
load_forecast += " (%s)" % (str(load_forecast10))
13121323

1313-
if rate_value_import <= 0: # colour the import rate, blue for negative, then green, yellow and red
1314-
rate_color_import = "#74C1FF"
1315-
elif rate_value_import <= import_cost_threshold:
1316-
rate_color_import = "#3AEE85"
1317-
elif rate_value_import > (import_cost_threshold * 1.5):
1318-
rate_color_import = "#F18261"
1324+
rate_color_import = import_rate_color(rate_value_import) # blue for negative, then green, yellow and red
13191325

13201326
if rate_value_export >= (1.5 * export_cost_threshold):
13211327
rate_color_export = "#F18261"
@@ -1501,16 +1507,26 @@ def publish_html_plan(self, pv_forecast_minute_step, pv_forecast_minute_step10,
15011507
cost_color = "#FFFFFF"
15021508

15031509
# Car charging?
1510+
car_rate = None
15041511
if self.num_cars > 0:
15051512
car_charging_kwh = self.car_charge_slot_kwh(minute_start, minute_end)
15061513
car_total += car_charging_kwh
15071514
if car_charging_kwh > 0.0:
15081515
car_charging_str = str(car_charging_kwh)
15091516
car_color = "FFFF00"
1517+
car_rate = self.car_charge_slot_rate(minute_start, minute_end)
15101518
else:
15111519
car_charging_str = "&#9866;"
15121520
car_color = "#FFFFFF"
15131521

1522+
# The car's own rate can diverge from the general household rate once its IOG dispatch
1523+
# cap is used up for the day - the car falls back to the peak rate while the house keeps
1524+
# its real (possibly still cheap) rate for the same clock-time (batpred#4646). Split the
1525+
# Import p cell to show both when that happens.
1526+
rate_split = car_rate is not None and abs(car_rate - rate_value_import) > 0.01
1527+
if rate_split:
1528+
car_rate_color = import_rate_color(car_rate)
1529+
15141530
# iBoost
15151531
iboost_amount_str = "&#9866;"
15161532
iboost_color = "#FFFFFF"
@@ -1582,7 +1598,16 @@ def publish_html_plan(self, pv_forecast_minute_step, pv_forecast_minute_step10,
15821598
# Table row
15831599
html += '<tr style="color:black">'
15841600
html += "<td id=time bgcolor=#FFFFFF>" + rate_start.strftime("%a %H:%M") + "</td>"
1585-
html += "<td id=import data-minute=" + str(minute) + " data-rate=" + str(rate_value_import) + " " + cell_style + " bgcolor=" + rate_color_import + ">" + str(rate_str_import) + " </td>"
1601+
if rate_split:
1602+
house_title = escape_html("House rate: {:.2f}{}/kWh".format(rate_value_import, self.currency_symbols[1]), quote=True)
1603+
car_title = escape_html("Car rate: {:.2f}{}/kWh (differs from house rate)".format(car_rate, self.currency_symbols[1]), quote=True)
1604+
html += "<td id=import data-minute=" + str(minute) + " data-rate=" + str(rate_value_import) + ' style="padding:0;">'
1605+
html += '<div style="display:flex;">'
1606+
html += '<div style="flex:1;padding:4px;background-color:' + rate_color_import + ';" title="' + house_title + '">' + str(rate_str_import) + "</div>"
1607+
html += '<div style="flex:1;padding:4px;background-color:' + car_rate_color + ';" title="' + car_title + '">' + "{:.2f}".format(car_rate) + "</div>"
1608+
html += "</div></td>"
1609+
else:
1610+
html += "<td id=import data-minute=" + str(minute) + " data-rate=" + str(rate_value_import) + " " + cell_style + " bgcolor=" + rate_color_import + ">" + str(rate_str_import) + " </td>"
15861611
html += "<td id=export data-minute=" + str(minute) + " data-rate=" + str(rate_value_export) + " " + cell_style + " bgcolor=" + rate_color_export + ">" + str(rate_str_export) + " </td>"
15871612
if start_span:
15881613
if split: # for slots that are both charging and exporting, just output the (split cell) state
@@ -1689,6 +1714,9 @@ def publish_html_plan(self, pv_forecast_minute_step, pv_forecast_minute_step10,
16891714
if self.num_cars > 0:
16901715
json_row["car_charging"] = car_charging_kwh
16911716
json_row["car_color"] = car_color
1717+
json_row["car_rate"] = car_rate
1718+
json_row["car_rate_color"] = car_rate_color if rate_split else None
1719+
json_row["rate_split"] = rate_split
16921720
if self.iboost_enable:
16931721
json_row["iboost"] = iboost_amount
16941722
json_row["iboost_change"] = iboost_change

apps/predbat/plan.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5256,6 +5256,37 @@ def car_charge_slot_kwh(self, minute_start, minute_end):
52565256
car_charging_kwh = dp2(car_charging_kwh)
52575257
return car_charging_kwh
52585258

5259+
def car_charge_slot_rate(self, minute_start, minute_end):
5260+
"""
5261+
Work out the car's own effective import rate (p/kWh) for the given
5262+
self.plan_interval_minutes-minute slot - the kWh-weighted average across any
5263+
car_charging_slots windows active in it. This can differ from the general household
5264+
import rate once a car's IOG dispatch cap is exhausted for the day (batpred#4646).
5265+
Returns None if no car charging in this slot.
5266+
"""
5267+
total_kwh = 0.0
5268+
total_cost = 0.0
5269+
if self.num_cars > 0:
5270+
for car_n in range(self.num_cars):
5271+
for window in self.car_charging_slots[car_n]:
5272+
if "average" not in window:
5273+
# Non-Octopus historical reconstruction (Yesterday view) appends slots from
5274+
# the car's own energy sensor with no rate at all - treating that as 0p/kWh
5275+
# would drag the weighted average down and falsely flag ordinary charging as
5276+
# diverging from the house rate. Skip rather than guess.
5277+
continue
5278+
start = window["start"]
5279+
end = window["end"]
5280+
if start < minute_end and end > minute_start and end != start:
5281+
overlap_start = max(start, minute_start)
5282+
overlap_end = min(end, minute_end)
5283+
kwh = dp2(window["kwh"]) * (overlap_end - overlap_start) / (end - start)
5284+
total_kwh += kwh
5285+
total_cost += kwh * window["average"]
5286+
if total_kwh > 0.0001:
5287+
return dp2(total_cost / total_kwh)
5288+
return None
5289+
52595290
def hit_car_window(self, window_start, window_end, cache=None):
52605291
"""Does this window intersect a car charging window?
52615292

apps/predbat/tests/test_octopus_slots.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,10 @@ def run_load_octopus_slots_tests(my_predbat):
8080
my_predbat.rate_max_base = 10
8181
my_predbat.car_charging_rate = [5.0]
8282
my_predbat.args["octopus_slot_max"] = 12
83+
# load_octopus_slots() short-circuits to [] when car_n >= self.num_cars - set this explicitly
84+
# rather than relying on whatever a previous test in the same run left num_cars as (a shared
85+
# my_predbat instance persists across tests within a run).
86+
my_predbat.num_cars = 1
8387

8488
# Created 8 slots in total in the next 16 hours
8589
soc = 2.0

apps/predbat/tests/test_plan_json_rate_adjust.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,70 @@ def run_test_plan_json_rate_adjust(my_predbat):
140140
my_predbat.rate_import_replicated = {}
141141
my_predbat.rate_export_replicated = {}
142142

143+
# --- Test 3: car rate diverging from the house rate (batpred#4646) ---
144+
print("Test plan JSON output with a car rate that diverges from the house rate")
145+
my_predbat.num_cars = 1
146+
car_minute = my_predbat.minutes_now
147+
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}]
148+
149+
html_plan, raw_plan = my_predbat.publish_html_plan(pv_step, pv_step, load_step, load_step, my_predbat.end_record, publish=False)
150+
car_row = next((row for row in raw_plan["rows"] if row.get("slot_minute") == car_minute), None)
151+
if car_row is None:
152+
print("WARNING: Could not find row for car minute {} in plan output".format(car_minute))
153+
else:
154+
if car_row.get("car_rate") != 28.0:
155+
print("ERROR: Expected car_rate=28.0 got {}".format(car_row.get("car_rate")))
156+
failed = True
157+
if car_row.get("rate_split") is not True:
158+
print("ERROR: Expected rate_split=True when car rate (28.0) diverges from house rate (10.0), got {}".format(car_row.get("rate_split")))
159+
failed = True
160+
if not car_row.get("car_rate_color"):
161+
print("ERROR: Expected car_rate_color to be set when rate_split is True")
162+
failed = True
163+
if "House rate: 10.00" not in html_plan or "Car rate: 28.00" not in html_plan:
164+
print("ERROR: Expected split-cell HTML with house and car rate tooltips, got:\n{}".format(html_plan))
165+
failed = True
166+
if "differs from house rate" not in html_plan:
167+
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))
168+
failed = True
169+
170+
# currency_symbols is user-configurable free text - a value carrying a double-quote must not
171+
# break out of the split cell's title="..." attribute in the server-rendered plan (batpred#4647
172+
# review). Checking for the specific escaped form within the title, not a blanket string search -
173+
# currency_symbols is also embedded unescaped elsewhere on the page (e.g. the Cost cell text,
174+
# pre-existing and out of scope for this fix), which would give a false pass/fail either way.
175+
saved_currency_symbols = my_predbat.currency_symbols
176+
breakout = '"><script>alert(1)</script>'
177+
my_predbat.currency_symbols = ["£", "p" + breakout]
178+
html_plan, raw_plan = my_predbat.publish_html_plan(pv_step, pv_step, load_step, load_step, my_predbat.end_record, publish=False)
179+
expected_escaped = "House rate: 10.00p&quot;&gt;&lt;script&gt;alert(1)&lt;/script&gt;/kWh"
180+
if expected_escaped not in html_plan:
181+
print("ERROR: expected the split-cell title to HTML-escape currency_symbols, wanted:\n{}\ngot:\n{}".format(expected_escaped, html_plan))
182+
failed = True
183+
my_predbat.currency_symbols = saved_currency_symbols
184+
185+
# Same car window, but priced the same as the house rate - must not split
186+
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}]
187+
html_plan, raw_plan = my_predbat.publish_html_plan(pv_step, pv_step, load_step, load_step, my_predbat.end_record, publish=False)
188+
car_row = next((row for row in raw_plan["rows"] if row.get("slot_minute") == car_minute), None)
189+
if car_row is not None and car_row.get("rate_split") is not False:
190+
print("ERROR: Expected rate_split=False when car rate matches house rate, got {}".format(car_row.get("rate_split")))
191+
failed = True
192+
193+
# A window with no "average" key at all (non-Octopus historical reconstruction in
194+
# calculate_yesterday() appends slots like this from the car's own energy sensor) must not be
195+
# treated as a free/0p charge - that would drag the weighted average down and falsely flag
196+
# ordinary charging as diverging from the house rate.
197+
my_predbat.car_charging_slots[0] = [{"start": car_minute, "end": car_minute + 30, "kwh": 3.0, "octopus": False}]
198+
rate = my_predbat.car_charge_slot_rate(car_minute, car_minute + 30)
199+
if rate is not None:
200+
print("ERROR: Expected car_charge_slot_rate to skip a window with no average key, got {}".format(rate))
201+
failed = True
202+
203+
# Clean up
204+
my_predbat.num_cars = 0
205+
my_predbat.car_charging_slots[0] = []
206+
143207
if not failed:
144208
print("All plan JSON rate adjust type tests passed")
145209
return failed

apps/predbat/web_helper.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6526,6 +6526,17 @@ def get_plan_renderer_js():
65266526
}
65276527
if (editable) {
65286528
html += renderRateCell(row.import_rate, row.rate_color_import, 'import', row.time, timeDisplay, overrides, importText, row.slot_minute);
6529+
} else if (row.rate_split) {
6530+
// Car's own rate has diverged from the house rate - not necessarily an IOG cap
6531+
// (any car window with its own average can diverge, e.g. combined dynamic-rate
6532+
// windows) - split the cell, house on the left, car on the right, own tooltip each.
6533+
const houseTitle = escapeAttr(`House rate: ${row.import_rate.toFixed(2)}${currencyMinor}/kWh`);
6534+
const carTitle = escapeAttr(`Car rate: ${row.car_rate.toFixed(2)}${currencyMinor}/kWh (differs from house rate)`);
6535+
html += `<td id=import data-minute="${row.slot_minute}" data-rate="${row.import_rate}" style="padding:0;">`;
6536+
html += `<div style="display:flex;">`;
6537+
html += `<div style="flex:1;padding:4px;background-color:${row.rate_color_import || '#FFFFFF'};" title="${houseTitle}">${importText}</div>`;
6538+
html += `<div style="flex:1;padding:4px;background-color:${row.car_rate_color || '#FFFFFF'};" title="${carTitle}">${row.car_rate.toFixed(2)}</div>`;
6539+
html += `</div></td>`;
65296540
} else {
65306541
html += `<td id=import ${cellStyle} bgcolor=${row.rate_color_import || '#FFFFFF'}>${importText}</td>`;
65316542
}

0 commit comments

Comments
 (0)