Skip to content
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
42 changes: 35 additions & 7 deletions apps/predbat/output.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -1069,6 +1070,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
Expand Down Expand Up @@ -1310,12 +1321,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"
Expand Down Expand Up @@ -1501,16 +1507,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 = "&#9866;"
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 = "&#9866;"
iboost_color = "#FFFFFF"
Expand Down Expand Up @@ -1582,7 +1598,16 @@ def publish_html_plan(self, pv_forecast_minute_step, pv_forecast_minute_step10,
# Table row
html += '<tr style="color:black">'
html += "<td id=time bgcolor=#FFFFFF>" + rate_start.strftime("%a %H:%M") + "</td>"
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>"
if rate_split:
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 += "<td id=import data-minute=" + str(minute) + " data-rate=" + str(rate_value_import) + ' style="padding:0;">'
html += '<div style="display:flex;">'
html += '<div style="flex:1;padding:4px;background-color:' + rate_color_import + ';" title="' + house_title + '">' + str(rate_str_import) + "</div>"
html += '<div style="flex:1;padding:4px;background-color:' + car_rate_color + ';" title="' + car_title + '">' + "{:.2f}".format(car_rate) + "</div>"
html += "</div></td>"
else:
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>"
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>"
if start_span:
if split: # for slots that are both charging and exporting, just output the (split cell) state
Expand Down Expand Up @@ -1689,6 +1714,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
Expand Down
31 changes: 31 additions & 0 deletions apps/predbat/plan.py
Original file line number Diff line number Diff line change
Expand Up @@ -5256,6 +5256,37 @@ 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]:
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:
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["average"]
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?

Expand Down
4 changes: 4 additions & 0 deletions apps/predbat/tests/test_octopus_slots.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
64 changes: 64 additions & 0 deletions apps/predbat/tests/test_plan_json_rate_adjust.py
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,70 @@ 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
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 = '"><script>alert(1)</script>'
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&quot;&gt;&lt;script&gt;alert(1)&lt;/script&gt;/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}]
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

# 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] = []

if not failed:
print("All plan JSON rate adjust type tests passed")
return failed
11 changes: 11 additions & 0 deletions apps/predbat/web_helper.py
Original file line number Diff line number Diff line change
Expand Up @@ -6526,6 +6526,17 @@ 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 - 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 (differs from house rate)`);
html += `<td id=import data-minute="${row.slot_minute}" data-rate="${row.import_rate}" style="padding:0;">`;
html += `<div style="display:flex;">`;
html += `<div style="flex:1;padding:4px;background-color:${row.rate_color_import || '#FFFFFF'};" title="${houseTitle}">${importText}</div>`;
html += `<div style="flex:1;padding:4px;background-color:${row.car_rate_color || '#FFFFFF'};" title="${carTitle}">${row.car_rate.toFixed(2)}</div>`;
html += `</div></td>`;
} else {
html += `<td id=import ${cellStyle} bgcolor=${row.rate_color_import || '#FFFFFF'}>${importText}</td>`;
}
Expand Down
Loading