diff --git a/apps/predbat/output.py b/apps/predbat/output.py index 6bd018b23..fa833e44e 100644 --- a/apps/predbat/output.py +++ b/apps/predbat/output.py @@ -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.", "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, 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. @@ -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 += " ⅎ" @@ -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 = " →" - 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 @@ -1338,7 +1361,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", "params": {}}) elif limit < 100: if not had_state: state = "" @@ -1354,7 +1377,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 4fe8932f6..659aa1088 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): @@ -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): @@ -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 = [] @@ -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: @@ -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
| ${innerHtml} | `; + } + // Render header html += '||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Time | '; - const currencyMinor = jsonData.currency_symbols?.[1] ?? 'p'; - html += showDebug ? `Import ${currencyMinor} (w/loss) | ` : `Import ${currencyMinor} | `; - html += showDebug ? `Export ${currencyMinor} (w/loss) | ` : `Export ${currencyMinor} | `; - html += 'State | '; - html += 'Limit % | '; - html += showDebug ? 'PV kWh (10%) | ' : 'PV kWh | '; - html += showDebug ? 'Load kWh (10%) | ' : 'Load kWh | '; + html += th('time', 'Time'); + 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 += 'Clip kWh | '; + html += th('clip', 'Clip kWh'); } if (showDebug && jsonData.rows.some(r => r.extra_load !== undefined)) { - html += 'XLoad kWh | '; + html += th('xload', 'XLoad kWh'); } if (jsonData.num_cars > 0) { - html += 'Car kWh | '; + html += th('car', 'Car kWh'); } if (jsonData.iboost_enable) { - html += 'iBoost kWh | '; + html += th('iboost', 'iBoost kWh'); } - html += 'SoC % | '; - html += 'Cost | '; - html += 'Total | '; + html += th('soc', 'SoC %'); + html += th('cost', 'Cost'); + html += th('total', 'Total'); if (jsonData.carbon_enable) { - html += 'CO2 g/kWh | '; - html += 'CO2 kg | '; + html += th('co2_rate', 'CO2 g/kWh'); + html += th('co2_total', 'CO2 kg'); } html += '|