Skip to content
Open
47 changes: 35 additions & 12 deletions apps/predbat/output.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,13 +35,13 @@
# Used for the first half of a split slot where the export window only starts partway through -
# deliberately worded without the "nothing is scheduled this slot" clause of the plain demand
# reasons above, which would contradict the export reason sitting alongside it in the same slot.
"demand_before_export_rising": "Until the export window starts partway through this slot, this is Demand mode - the battery level is expected to rise from solar generation.",
"demand_before_export_falling": "Until the export window starts partway through this slot, this is Demand mode - the battery is expected to discharge to cover house load.",
"demand_before_export_steady": "Until the export window starts partway through this slot, this is Demand mode - the battery level is expected to stay steady.",
"freeze_charge": "Freeze charging — the battery holds at the current level rather than charging further this slot (import rate {rate}p/kWh vs. your {threshold}p/kWh threshold).",
"demand_before_export_rising": "Until {split_time}, the battery level is expected to rise from solar generation.",
"demand_before_export_falling": "Until {split_time}, the battery is expected to discharge to cover house demand.",
"demand_before_export_steady": "Until {split_time}, the battery level is expected to stay steady.",
"freeze_charge": "Freeze charging — the battery holds at the current level rather than charging further this slot (import rate {rate}p/kWh vs. the calculated {threshold}p/kWh threshold).",
"hold_charge_at_target": "Holding — the battery is already predicted to be at or above the {target_percent}% target for this window without charging further.",
"charge_low_rate": "Charging up to {target_percent}% at the import rate for this slot of ({rate}p/kWh).",
"freeze_export_below_threshold": "Freezing export — excess solar is exported to the grid (export rate {rate}p/kWh vs. your {threshold}p/kWh threshold).",
"freeze_export": "Freezing export — solar surplus passes straight to the grid, but it's not worth discharging the battery to sell more this slot.",
"hold_export_unreachable": "Export window active but not triggered — the battery isn't predicted to reach the {target_percent}% level needed to export this slot.",
"export_high_rate": "Exporting down to {target_percent}% at the export rate of ({rate}p/kWh) using stored energy back to the grid.",
Comment thread
chalfontchubby marked this conversation as resolved.
"manual_override_charge": "You manually set this slot to charge.",
Expand Down Expand Up @@ -523,6 +523,21 @@ def publish_rates_import(self):
attributes={"friendly_name": "Next+1 low rate cost", "state_class": "measurement", "unit_of_measurement": self.currency_symbols[1], "icon": "mdi:currency-usd"},
)

def rate_range_text(self, rate_dict, start_minute, end_minute, fallback_value):
"""
Format a rate as a single value, or a "{min}-{max}" range when the minutes from
start_minute to end_minute (a merged/rowspan plan cell) don't all share the same rate.
"""
values = set()
for minute in range(start_minute, end_minute, self.plan_interval_minutes):
values.add(dp2(rate_dict.get(minute, fallback_value)))
if not values:
return "{:.2f}".format(fallback_value)
low, high = min(values), max(values)
if low == high:
return "{:.2f}".format(low)
return "{:.2f}-{:.2f}".format(low, high)

def adjust_symbol(self, adjust_type):
"""
Returns an HTML symbol based on the adjust rate type.
Expand Down Expand Up @@ -1042,6 +1057,11 @@ def publish_html_plan(self, pv_forecast_minute_step, pv_forecast_minute_step10,
rate_start = minute_timestamp
rate_value_import = dp2(self.rate_import.get(minute, 0))
rate_value_export = dp2(self.rate_export.get(minute, 0))
# Default to a single value; overridden to a "{min}-{max}" range below when this row
# turns out to be the first of a merged/rowspan cell whose minutes span more than one
# distinct rate - only the first row of a span is ever actually rendered as a tooltip.
rate_text_import = "{:.2f}".format(rate_value_import)
rate_text_export = "{:.2f}".format(rate_value_export)
charge_window_n = -1
export_window_n = -1
periods_left = int((end_plan - minute + self.plan_interval_minutes - 1) / self.plan_interval_minutes)
Expand Down Expand Up @@ -1085,6 +1105,7 @@ def publish_html_plan(self, pv_forecast_minute_step, pv_forecast_minute_step10,
in_span = True
start_span = True
minute_relative_end = self.charge_window_best[charge_window_n]["end"] - minute_now_align
rate_text_import = self.rate_range_text(self.rate_import, minute, charge_end_minute, rate_value_import)
else:
rowspan = 0

Expand All @@ -1096,6 +1117,7 @@ def publish_html_plan(self, pv_forecast_minute_step, pv_forecast_minute_step10,
in_span = True
start_span = True
minute_relative_end = self.export_window_best[export_window_n]["end"] - minute_now_align
rate_text_export = self.rate_range_text(self.rate_export, minute, export_end_minute, rate_value_export)
else:
rowspan = 0

Expand Down Expand Up @@ -1275,7 +1297,7 @@ def publish_html_plan(self, pv_forecast_minute_step, pv_forecast_minute_step10,
state_color = "#EEEEEE"
raw_state = "FrzChrg"
limit_percent = soc_percent
reason_parts.append({"code": "freeze_charge", "params": {"rate": "{:.2f}".format(rate_value_import), "threshold": "{:.2f}".format(import_cost_threshold)}})
reason_parts.append({"code": "freeze_charge", "params": {"rate": rate_text_import, "threshold": "{:.2f}".format(import_cost_threshold)}})
elif limit_percent <= soc_percent_min_window:
state = "HoldChrg&rarr;"
state_color = "#34DBEB"
Expand All @@ -1285,7 +1307,7 @@ def publish_html_plan(self, pv_forecast_minute_step, pv_forecast_minute_step10,
state = "Chrg&nearr;"
state_color = "#3AEE85"
raw_state = "Chrg"
reason_parts.append({"code": "charge_low_rate", "params": {"target_percent": limit_percent, "rate": "{:.2f}".format(rate_value_import)}})
reason_parts.append({"code": "charge_low_rate", "params": {"target_percent": limit_percent, "rate": rate_text_import}})

if self.charge_window_best[charge_window_n]["start"] in self.manual_charge_times:
state += " &#8526;"
Expand All @@ -1305,18 +1327,19 @@ def publish_html_plan(self, pv_forecast_minute_step, pv_forecast_minute_step10,
start = self.export_window_best[export_window_n]["start"]
if start > minute:
soc_change_this = self.predict_soc_best.get(max(start - self.minutes_now, 0), 0.0) - self.predict_soc_best.get(minute_relative_start, 0.0)
split_time_str = (self.midnight_utc + timedelta(minutes=start)).strftime("%H:%M")
# Same near-flat tolerance as the whole-slot demand arrow above - testing
# soc_change_this >= 0 first would make the steady case unreachable and
# render a flat pre-window period as rising
if abs(soc_change_this) < 0.05:
state = " &rarr;"
reason_parts.append({"code": "demand_before_export_steady", "params": {}})
reason_parts.append({"code": "demand_before_export_steady", "params": {"split_time": split_time_str}})
elif soc_change_this >= 0:
state = " &nearr;"
reason_parts.append({"code": "demand_before_export_rising", "params": {}})
reason_parts.append({"code": "demand_before_export_rising", "params": {"split_time": split_time_str}})
else:
state = " &searr;"
reason_parts.append({"code": "demand_before_export_falling", "params": {}})
reason_parts.append({"code": "demand_before_export_falling", "params": {"split_time": split_time_str}})
state_color = "#FFFFFF"
show_limit = ""
had_state = True
Expand All @@ -1338,7 +1361,7 @@ def publish_html_plan(self, pv_forecast_minute_step, pv_forecast_minute_step10,
state += "FrzExp&rarr;"
raw_state = "FrzExp"
show_limit = "" # suppress displaying the limit (of 99) when freeze exporting as its a meaningless number
reason_parts.append({"code": "freeze_export_below_threshold", "params": {"rate": "{:.2f}".format(rate_value_export), "threshold": "{:.2f}".format(export_cost_threshold)}})
reason_parts.append({"code": "freeze_export", "params": {}})
elif limit < 100:
if not had_state:
state = ""
Expand All @@ -1354,7 +1377,7 @@ def publish_html_plan(self, pv_forecast_minute_step, pv_forecast_minute_step10,
else:
state += "Exp&searr;"
raw_state = "Exp"
reason_parts.append({"code": "export_high_rate", "params": {"target_percent": dp2(target), "rate": "{:.2f}".format(rate_value_export)}})
reason_parts.append({"code": "export_high_rate", "params": {"target_percent": dp2(target), "rate": rate_text_export}})
show_limit = str(dp2(target))
raw_state_target = str(dp2(target))

Expand Down
101 changes: 90 additions & 11 deletions apps/predbat/tests/test_plan_why_reason.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

import re
import warnings
from datetime import timedelta

import web_helper
from prediction import Prediction
Expand Down Expand Up @@ -78,18 +79,22 @@ def _codes(row):
def _render(row, templates):
"""
Mirror of the client-side renderReasonText() in web_helper.py: fill in each reason
entry's template with its params, join with a space. Used here to verify the code/params/
template contract produces the expected human-readable text end-to-end, not just that the
right code was picked.
entry's template with its params, join with a space, prefixing "Then" onto the second half
of a demand-before-export split. Used here to verify the code/params/template contract
produces the expected human-readable text end-to-end, not just that the right code was picked.
"""
parts = []
for entry in row.get("reasons", []):
reasons = row.get("reasons", [])
rendered = []
for entry in reasons:
template = templates.get(entry["code"])
if not template:
rendered.append("")
continue
text = re.sub(r"\{(\w+)\}", lambda m: str(entry["params"].get(m.group(1), m.group(0))), template)
parts.append(text)
return " ".join(parts)
rendered.append(text)
if len(reasons) == 2 and rendered[0] and rendered[1] and reasons[0]["code"].startswith("demand_before_export_"):
rendered[1] = "Then " + rendered[1][0].lower() + rendered[1][1:]
return " ".join(part for part in rendered if part)


def run_test_plan_why_reason(my_predbat):
Expand Down Expand Up @@ -223,10 +228,10 @@ def render():
my_predbat.export_limits_best = [99]
_, raw_plan = render()
row = _get_row(raw_plan, minutes_now)
if row is None or _codes(row) != ["freeze_export_below_threshold"]:
if row is None or _codes(row) != ["freeze_export"]:
print("ERROR: FrzExp reasons unexpected: {}".format(row and _codes(row)))
failed = True
elif set(row["reasons"][0]["params"]) != {"rate", "threshold"}:
elif row["reasons"][0]["params"] != {}:
print("ERROR: FrzExp params unexpected: {}".format(row["reasons"][0]["params"]))
failed = True
elif "Freezing export" not in _render(row, templates):
Expand All @@ -248,6 +253,55 @@ def render():
failed = True
my_predbat.manual_export_times = []

# --- Test 8b: merged/rowspan export cell shows a rate range, not just the first slot's rate ---
print("Test merged export cell reason shows a rate range")
span_window = [{"start": minutes_now, "end": minutes_now + 90, "average": 20.0}]
my_predbat.export_window_best = span_window
my_predbat.export_limits_best = [50.0]
my_predbat.predict_soc_best = _flat_soc(my_predbat, 9.0) # 90%, well above the 50% target
my_predbat.rate_export[minutes_now] = 15.0
my_predbat.rate_export[minutes_now + 30] = 25.0
my_predbat.rate_export[minutes_now + 60] = 20.0
_, raw_plan = render()
row = _get_row(raw_plan, minutes_now)
if row is None or _codes(row) != ["export_high_rate"]:
print("ERROR: merged export reasons unexpected: {}".format(row and _codes(row)))
failed = True
elif row["reasons"][0]["params"].get("rate") != "15.00-25.00":
print("ERROR: merged export rate range unexpected: {}".format(row["reasons"][0]["params"]))
failed = True
elif "15.00-25.00" not in _render(row, templates):
print("ERROR: merged export rendered text missing the rate range: {}".format(_render(row, templates)))
failed = True

# A single-slot window (no merge) must still show a plain single value, not a spurious range.
print("Test single-slot export cell reason still shows a single rate, not a range")
my_predbat.export_window_best = window
_, raw_plan = render()
row = _get_row(raw_plan, minutes_now)
if row is None or "-" in row["reasons"][0]["params"].get("rate", ""):
print("ERROR: single-slot export rate should not be a range: {}".format(row and row["reasons"][0]["params"]))
failed = True

# A minute within the merged span missing from rate_export must fall back to the row's own
# known rate, not silently default to 0 (regression: rate_range_text originally defaulted a
# missing minute to 0 rather than the fallback_value it was given, which could widen a range
# to a spurious "0.00-20.00" - Copilot review finding on PR #4362).
print("Test merged export cell falls back to the row's own rate for a missing minute, not 0")
my_predbat.export_window_best = span_window
my_predbat.rate_export[minutes_now] = 20.0
my_predbat.rate_export[minutes_now + 60] = 20.0
del my_predbat.rate_export[minutes_now + 30]
_, raw_plan = render()
row = _get_row(raw_plan, minutes_now)
if row is None or row["reasons"][0]["params"].get("rate") != "20.00":
print("ERROR: merged export rate with a missing minute unexpected: {}".format(row and row["reasons"][0]["params"]))
failed = True
my_predbat.export_window_best = window
my_predbat.rate_export[minutes_now] = 5.0
my_predbat.rate_export[minutes_now + 30] = 5.0
my_predbat.rate_export[minutes_now + 60] = 5.0

# --- Test 9: Demand (no charge or export window active) ---
print("Test Demand default reason")
my_predbat.export_window_best = []
Expand Down Expand Up @@ -295,8 +349,15 @@ def render():
failed = True
else:
rendered = _render(row, templates)
if "Until the export window starts" not in rendered or "Exporting down to" not in rendered:
print("ERROR: split slot tooltip should explain both halves, got: {}".format(rendered))
expected_split_time = (my_predbat.midnight_utc + timedelta(minutes=minutes_now + 15)).strftime("%H:%M")
if "Until {}".format(expected_split_time) not in rendered:
print("ERROR: split slot tooltip should state the exact split time, got: {}".format(rendered))
failed = True
elif "Then exporting down to" not in rendered:
print("ERROR: split slot tooltip should join both halves with a lowercase 'Then ', got: {}".format(rendered))
failed = True
elif "Then," in rendered:
print("ERROR: split slot tooltip should not put a comma after 'Then', got: {}".format(rendered))
failed = True
# The pre-window wording must not claim nothing is scheduled - the slot does export later
if "no charging or exporting is scheduled" in rendered:
Expand Down Expand Up @@ -409,6 +470,24 @@ def render():
print("ERROR: expected renderPlanTable to take reason_templates from the dataset it renders")
failed = True

# --- Test 13c: plan table column headers get a hover tooltip explaining what they mean ---
print("Test column headers carry a title= explaining what each column means")
if "COLUMN_HEADER_HELP" not in renderer_js:
print("ERROR: expected a COLUMN_HEADER_HELP lookup for column header tooltips")
failed = True
if "function th(key, innerHtml" not in renderer_js:
print("ERROR: expected a th() helper wiring COLUMN_HEADER_HELP into <th> title= attributes")
failed = True
# Every column referenced by the header-rendering block must have a corresponding help entry -
# a silently missing key would just render no tooltip rather than fail loudly, so check directly.
header_start = renderer_js.index("const COLUMN_HEADER_HELP")
header_block_end = renderer_js.index("function th(key", header_start)
header_help_block = renderer_js[header_start:header_block_end]
for key in ["time", "import", "export", "state", "limit", "pv", "load", "clip", "xload", "car", "iboost", "soc", "cost", "total", "co2_rate", "co2_total"]:
if "{}:".format(key) not in header_help_block:
print("ERROR: COLUMN_HEADER_HELP is missing an entry for '{}'".format(key))
failed = True

# --- Test 14: the renderer JS source has no invalid Python escape sequences ---
# The JS regexes live inside plain (non-raw) triple-quoted Python strings, so a backslash
# intended for JS must be doubled. A single "\{" raises SyntaxWarning today and becomes a
Expand Down
Loading
Loading