From 10674bd3c08c071200fd1e8a85be59886453a317 Mon Sep 17 00:00:00 2001 From: Rik Allen Date: Wed, 29 Jul 2026 09:12:54 +0100 Subject: [PATCH 1/7] feat(plan): show a rate range for merged plan cells, fix threshold wording Merged/rowspan cells (consecutive slots sharing the same state) only ever showed the first slot's rate in the tooltip, even when the underlying rate varied across the span. rowspan and the window's own end minute are already known at the point a span starts, and the per-minute rate data is already fully populated by then too, so the span's actual min-max range can be computed in the same place rowspan already gets computed, with no restructuring needed - falls back to a single value when the span is one slot or the rate doesn't vary across it. Also: "vs. your {threshold}p/kWh threshold" implied a user-configured setting, but the threshold is calculated by the optimiser from the selected windows' own rates (find_price_levels), not something set directly - reworded to "the calculated {threshold}p/kWh threshold". --- apps/predbat/output.py | 34 ++++++++++++++++++---- apps/predbat/tests/test_plan_why_reason.py | 33 +++++++++++++++++++++ 2 files changed, 61 insertions(+), 6 deletions(-) diff --git a/apps/predbat/output.py b/apps/predbat/output.py index f15401bd0..e8e732abb 100644 --- a/apps/predbat/output.py +++ b/apps/predbat/output.py @@ -38,10 +38,10 @@ "demand_before_export_rising": "Until the export window starts partway through this slot, the battery level is expected to rise from solar generation.", "demand_before_export_falling": "Until the export window starts partway through this slot, the battery is expected to discharge to cover house demand.", "demand_before_export_steady": "Until the export window starts partway through this slot, 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).", + "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_below_threshold": "Freezing export — excess solar is exported to the grid (export rate {rate}p/kWh vs. the calculated {threshold}p/kWh threshold).", "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.", "manual_override_charge": "You manually set this slot to charge.", @@ -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, 0))) + 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. @@ -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) @@ -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 @@ -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 @@ -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→" state_color = "#34DBEB" @@ -1285,7 +1307,7 @@ def publish_html_plan(self, pv_forecast_minute_step, pv_forecast_minute_step10, state = "Chrg↗" 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 += " ⅎ" @@ -1338,7 +1360,7 @@ def publish_html_plan(self, pv_forecast_minute_step, pv_forecast_minute_step10, state += "FrzExp→" 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_below_threshold", "params": {"rate": rate_text_export, "threshold": "{:.2f}".format(export_cost_threshold)}}) elif limit < 100: if not had_state: state = "" @@ -1354,7 +1376,7 @@ def publish_html_plan(self, pv_forecast_minute_step, pv_forecast_minute_step10, else: state += "Exp↘" 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)) diff --git a/apps/predbat/tests/test_plan_why_reason.py b/apps/predbat/tests/test_plan_why_reason.py index 6153b430c..2de94c135 100644 --- a/apps/predbat/tests/test_plan_why_reason.py +++ b/apps/predbat/tests/test_plan_why_reason.py @@ -248,6 +248,39 @@ 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 + 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 = [] From 898eff29f9ccd767ecc4d4524fa50b8cd2957ee7 Mon Sep 17 00:00:00 2001 From: Rik Allen Date: Wed, 29 Jul 2026 09:17:50 +0100 Subject: [PATCH 2/7] feat(plan): add hover tooltips explaining each plan table column header Column headers like "XLoad kWh" aren't self-explanatory to non-technical users. Added a title= tooltip to every plan-table , condensed from the existing column-by-column descriptions in predbat-plan-card.md (short, hover-appropriate summaries rather than the full colour-coding detail from the docs page). New th(key, innerHtml) helper wires a COLUMN_HEADER_HELP lookup into each header consistently. --- apps/predbat/tests/test_plan_why_reason.py | 18 +++++++ apps/predbat/web_helper.py | 60 ++++++++++++++++------ 2 files changed, 62 insertions(+), 16 deletions(-) diff --git a/apps/predbat/tests/test_plan_why_reason.py b/apps/predbat/tests/test_plan_why_reason.py index 2de94c135..278dbafb0 100644 --- a/apps/predbat/tests/test_plan_why_reason.py +++ b/apps/predbat/tests/test_plan_why_reason.py @@ -401,6 +401,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 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 diff --git a/apps/predbat/web_helper.py b/apps/predbat/web_helper.py index 02a9ed769..8d6790603 100644 --- a/apps/predbat/web_helper.py +++ b/apps/predbat/web_helper.py @@ -6385,34 +6385,62 @@ def get_plan_renderer_js(): let html = ''; const cellStyle = 'style="padding: 4px;"'; + // Short explanations for each plan-table column header, condensed from the full + // descriptions in predbat-plan-card.md - keep these brief, a hover tooltip is not + // the place for the doc page's colour-coding detail. + const COLUMN_HEADER_HELP = { + time: 'Predbat plans in slots (30 minutes by default) aligned to rate change times.', + import: 'The import rate for this slot, in pence per kWh. Bold if a charge is planned this slot.', + export: 'The export rate for this slot, in pence per kWh. Bold if a discharge/export is planned this slot.', + state: "What the battery is doing this slot - hover a state cell for the specific reason.", + limit: 'The battery SoC Predbat is planning to reach by the end of this slot.', + pv: 'Predicted solar generation for this slot, from the Solcast forecast.', + load: 'Predicted house electricity consumption for this slot, from historical data.', + clip: "Solar energy predicted to be lost - the inverter can't handle all the PV generated, or an export limit is set.", + xload: 'Extra load added externally via load_forecast settings (e.g. PredAI, PredHeat).', + car: 'Predicted car charging energy for this slot.', + iboost: 'Energy planned for the solar diverter (iBoost, MyEnergi Eddi, etc) this slot.', + soc: 'Estimated battery state of charge at the start of this slot.', + cost: 'Estimated cost (or saving) for this slot.', + total: 'Running total cost for today so far, at the start of this slot.', + co2_rate: 'Estimated carbon intensity of the grid at the start of this slot.', + co2_total: 'Estimated cumulative carbon footprint at the start of this slot.', + }; + + function th(key, innerHtml, extraAttrs) { + const helpText = COLUMN_HEADER_HELP[key]; + const titleAttr = helpText ? ` title="${escapeAttr(helpText)}"` : ''; + return `${innerHtml}`; + } + // Render header html += ''; - html += ''; + html += th('time', 'Time'); const currencyMinor = jsonData.currency_symbols?.[1] ?? 'p'; - html += showDebug ? `` : ``; - html += showDebug ? `` : ``; - html += ''; - html += ''; - html += showDebug ? '' : ''; - html += showDebug ? '' : ''; + html += showDebug ? th('import', `Import ${currencyMinor} (w/loss)`) : th('import', `Import ${currencyMinor}`); + html += showDebug ? th('export', `Export ${currencyMinor} (w/loss)`) : th('export', `Export ${currencyMinor}`); + html += th('state', 'State', ' colspan="2"'); + html += th('limit', 'Limit %'); + html += showDebug ? th('pv', 'PV kWh (10%)') : th('pv', 'PV kWh'); + html += showDebug ? th('load', 'Load kWh (10%)') : th('load', 'Load kWh'); if (showDebug) { - html += ''; + html += th('clip', 'Clip kWh'); } if (showDebug && jsonData.rows.some(r => r.extra_load !== undefined)) { - html += ''; + html += th('xload', 'XLoad kWh'); } if (jsonData.num_cars > 0) { - html += ''; + html += th('car', 'Car kWh'); } if (jsonData.iboost_enable) { - html += ''; + html += th('iboost', 'iBoost kWh'); } - html += ''; - html += ''; - html += ''; + html += th('soc', 'SoC %'); + html += th('cost', 'Cost'); + html += th('total', 'Total'); if (jsonData.carbon_enable) { - html += ''; - html += ''; + html += th('co2_rate', 'CO2 g/kWh'); + html += th('co2_total', 'CO2 kg'); } html += ''; From 4fac08da31d3fea3ffa9b52f8d0e593db4082b56 Mon Sep 17 00:00:00 2001 From: Rik Allen Date: Wed, 29 Jul 2026 09:28:11 +0100 Subject: [PATCH 3/7] fix(plan): explain the real freeze-export mechanism, not a fake threshold freeze_export_below_threshold implied a rate-vs-threshold cutoff the code doesn't actually enforce (same overclaim pattern already flagged for FrzChrg). Checked prediction.py directly: at limit==99, charging is zeroed (line ~790) and the discharge-to-export path is gated to limit < 99.0 (line ~818), so the battery does neither - solar surplus just has nowhere to go but export, at zero round-trip loss, while actively discharging to sell more isn't worth the loss this slot. Reworded to state that mechanism directly instead of a number-vs-number comparison that was never how the decision actually gets made. Renamed the reason code from freeze_export_below_threshold to freeze_export (matching freeze_charge) since the new wording no longer references a threshold, and dropped the now-unused rate/threshold params. --- apps/predbat/output.py | 4 ++-- apps/predbat/tests/test_plan_why_reason.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/predbat/output.py b/apps/predbat/output.py index e8e732abb..1899872c9 100644 --- a/apps/predbat/output.py +++ b/apps/predbat/output.py @@ -41,7 +41,7 @@ "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. the calculated {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.", "manual_override_charge": "You manually set this slot to charge.", @@ -1360,7 +1360,7 @@ def publish_html_plan(self, pv_forecast_minute_step, pv_forecast_minute_step10, state += "FrzExp→" 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": rate_text_export, "threshold": "{:.2f}".format(export_cost_threshold)}}) + reason_parts.append({"code": "freeze_export", "params": {}}) elif limit < 100: if not had_state: state = "" diff --git a/apps/predbat/tests/test_plan_why_reason.py b/apps/predbat/tests/test_plan_why_reason.py index 278dbafb0..99030d3fc 100644 --- a/apps/predbat/tests/test_plan_why_reason.py +++ b/apps/predbat/tests/test_plan_why_reason.py @@ -223,10 +223,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): From aaca5403efb1e73a07f53283997ac571b9191ad8 Mon Sep 17 00:00:00 2001 From: Rik Allen Date: Wed, 29 Jul 2026 09:36:06 +0100 Subject: [PATCH 4/7] feat(plan): state the exact split time and join both halves with "Then" A split cell's pre-window reason said "Until the export window starts partway through this slot" without saying when - the export window's own start minute was already in scope at that point, just not threaded through. Added {split_time} to the demand_before_export_* templates, formatted the same way output.py already formats the row's own timestamp (midnight_utc + timedelta). Also joins the two halves into one narrative instead of two disconnected sentences: renderReasonText() (and its Python test mirror) now prefixes "Then " (no comma) onto the second half specifically when the first is a demand_before_export_* code, lowercasing its first letter so it reads as a continuation rather than two capitalised sentences. --- apps/predbat/output.py | 13 +++++----- apps/predbat/tests/test_plan_why_reason.py | 30 +++++++++++++++------- apps/predbat/web_helper.py | 28 +++++++++++--------- 3 files changed, 44 insertions(+), 27 deletions(-) diff --git a/apps/predbat/output.py b/apps/predbat/output.py index 1899872c9..9a85e10b9 100644 --- a/apps/predbat/output.py +++ b/apps/predbat/output.py @@ -35,9 +35,9 @@ # 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, the battery level is expected to rise from solar generation.", - "demand_before_export_falling": "Until the export window starts partway through this slot, the battery is expected to discharge to cover house demand.", - "demand_before_export_steady": "Until the export window starts partway through this slot, the battery level is expected to stay steady.", + "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).", @@ -1327,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 = " →" - 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 = " ↗" - 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 = " ↘" - 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 diff --git a/apps/predbat/tests/test_plan_why_reason.py b/apps/predbat/tests/test_plan_why_reason.py index 99030d3fc..d2aa3ce9d 100644 --- a/apps/predbat/tests/test_plan_why_reason.py +++ b/apps/predbat/tests/test_plan_why_reason.py @@ -10,6 +10,7 @@ import re import warnings +from datetime import timedelta import web_helper from prediction import Prediction @@ -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): @@ -328,8 +333,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: diff --git a/apps/predbat/web_helper.py b/apps/predbat/web_helper.py index 8d6790603..d5db0df06 100644 --- a/apps/predbat/web_helper.py +++ b/apps/predbat/web_helper.py @@ -6732,18 +6732,22 @@ def get_plan_renderer_js(): if (!reasons || !templates) { return ''; } - return reasons - .map(function (entry) { - const template = templates[entry.code]; - if (!template) { - return ''; - } - return template.replace(/\\{(\\w+)\\}/g, function (match, key) { - return entry.params && entry.params[key] !== undefined ? entry.params[key] : match; - }); - }) - .filter(Boolean) - .join(' '); + const rendered = reasons.map(function (entry) { + const template = templates[entry.code]; + if (!template) { + return ''; + } + return template.replace(/\\{(\\w+)\\}/g, function (match, key) { + return entry.params && entry.params[key] !== undefined ? entry.params[key] : match; + }); + }); + // A split cell's first half is always a demand_before_export_* code paired with the export + // reason as its second half - prefix "Then" (no comma) so the two read as one narrative + // instead of two disconnected sentences. + if (reasons.length === 2 && rendered[0] && rendered[1] && typeof reasons[0].code === 'string' && reasons[0].code.indexOf('demand_before_export_') === 0) { + rendered[1] = 'Then ' + rendered[1].charAt(0).toLowerCase() + rendered[1].slice(1); + } + return rendered.filter(Boolean).join(' '); } // Build the ` title="..."` tooltip attribute for a row's state cell, or '' when the row From 12633684a9b75e74571c10b9e3b48497026ba5df Mon Sep 17 00:00:00 2001 From: Trefor Southwell <48591903+springfall2008@users.noreply.github.com> Date: Wed, 29 Jul 2026 15:34:03 +0200 Subject: [PATCH 5/7] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- apps/predbat/output.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/predbat/output.py b/apps/predbat/output.py index 9a85e10b9..c99fc8eaf 100644 --- a/apps/predbat/output.py +++ b/apps/predbat/output.py @@ -530,7 +530,7 @@ def rate_range_text(self, rate_dict, start_minute, end_minute, fallback_value): """ values = set() for minute in range(start_minute, end_minute, self.plan_interval_minutes): - values.add(dp2(rate_dict.get(minute, 0))) + values.add(dp2(rate_dict.get(minute, fallback_value))) if not values: return "{:.2f}".format(fallback_value) low, high = min(values), max(values) From e28a5de37940b4afaa4157ce20fa275ccce5ba98 Mon Sep 17 00:00:00 2001 From: Rik Allen Date: Wed, 29 Jul 2026 14:47:06 +0100 Subject: [PATCH 6/7] test(plan): cover rate_range_text's fallback for a missing minute The Copilot Autofix commit on this PR fixed rate_range_text() defaulting a minute missing from the rate dict to 0 instead of the given fallback_value - that gap wasn't covered by the existing merged-cell tests, which always populated every minute in the span. Added a case that deletes a minute from rate_export mid-span and asserts the range uses the row's own known rate, not a spurious 0. Verified this test fails without the fix (0.00-20.00 instead of 20.00). --- apps/predbat/tests/test_plan_why_reason.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/apps/predbat/tests/test_plan_why_reason.py b/apps/predbat/tests/test_plan_why_reason.py index d2aa3ce9d..cc5533936 100644 --- a/apps/predbat/tests/test_plan_why_reason.py +++ b/apps/predbat/tests/test_plan_why_reason.py @@ -282,6 +282,22 @@ def render(): 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 From f652c1cf9a410da96fd54eedd01dd9ceca1c61d7 Mon Sep 17 00:00:00 2001 From: Rik Allen Date: Sun, 2 Aug 2026 20:48:55 +0100 Subject: [PATCH 7/7] fix(plan): use the configured currency's minor unit in header tooltips, harden th() extraAttrs Per Copilot review on #4362: - COLUMN_HEADER_HELP hardcoded "pence per kWh" for the import/export column tooltips, which is wrong for non-GBP currencies. Now interpolates the same currencyMinor symbol already used in the header labels themselves (e.g. "Import p"), rather than spelling out an English currency name. - th()'s extraAttrs required callers to remember a leading space or produce invalid markup (e.g. ""). Now trims and adds the space inside th() itself. --- apps/predbat/web_helper.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/apps/predbat/web_helper.py b/apps/predbat/web_helper.py index 0feda42ba..75952066e 100644 --- a/apps/predbat/web_helper.py +++ b/apps/predbat/web_helper.py @@ -6423,10 +6423,11 @@ def get_plan_renderer_js(): // Short explanations for each plan-table column header, condensed from the full // descriptions in predbat-plan-card.md - keep these brief, a hover tooltip is not // the place for the doc page's colour-coding detail. + const currencyMinor = jsonData.currency_symbols?.[1] ?? 'p'; const COLUMN_HEADER_HELP = { time: 'Predbat plans in slots (30 minutes by default) aligned to rate change times.', - import: 'The import rate for this slot, in pence per kWh. Bold if a charge is planned this slot.', - export: 'The export rate for this slot, in pence per kWh. Bold if a discharge/export is planned this slot.', + import: `The import rate for this slot, in ${currencyMinor} per kWh. Bold if a charge is planned this slot.`, + export: `The export rate for this slot, in ${currencyMinor} per kWh. Bold if a discharge/export is planned this slot.`, state: "What the battery is doing this slot - hover a state cell for the specific reason.", limit: 'The battery SoC Predbat is planning to reach by the end of this slot.', pv: 'Predicted solar generation for this slot, from the Solcast forecast.', @@ -6445,13 +6446,13 @@ def get_plan_renderer_js(): function th(key, innerHtml, extraAttrs) { const helpText = COLUMN_HEADER_HELP[key]; const titleAttr = helpText ? ` title="${escapeAttr(helpText)}"` : ''; - return `${innerHtml}`; + const attrs = extraAttrs ? ` ${extraAttrs.trim()}` : ''; + return `${innerHtml}`; } // Render header html += ''; html += th('time', 'Time'); - const currencyMinor = jsonData.currency_symbols?.[1] ?? 'p'; html += showDebug ? th('import', `Import ${currencyMinor} (w/loss)`) : th('import', `Import ${currencyMinor}`); html += showDebug ? th('export', `Export ${currencyMinor} (w/loss)`) : th('export', `Export ${currencyMinor}`); html += th('state', 'State', ' colspan="2"');
TimeImport ${currencyMinor} (w/loss)Import ${currencyMinor}Export ${currencyMinor} (w/loss)Export ${currencyMinor}StateLimit %PV kWh (10%)PV kWhLoad kWh (10%)Load kWhClip kWhXLoad kWhCar kWhiBoost kWhSoC %CostTotalCO2 g/kWhCO2 kg