diff --git a/apps/predbat/gateway.py b/apps/predbat/gateway.py index 7991af2b9..b03355069 100644 --- a/apps/predbat/gateway.py +++ b/apps/predbat/gateway.py @@ -856,6 +856,48 @@ async def _publish_predbat_data(self): pass saving_month_average = round(float(saving_total) * 365 / 12 / total_days_of_savings, 2) + # Marginal cost matrix — "what does an extra 1/2/4/8 kWh of load cost + # me right now and in each of the next 6 two-hour windows?". Computed + # by the Marginal mixin via what-if prediction runs. Used by the + # gateway's appliance RAG to pick a colour that actually reflects the + # cost of running the dryer/EV/etc, rather than inferring from slot + # categories alone. + marginal_costs = [] + marginal_time_labels = [] + try: + matrix = self.get_state_wrapper("sensor." + self.prefix + "_marginal_energy_costs", attribute="matrix") + if isinstance(matrix, dict) and matrix: + # Canonical level order matches MARGINAL_EXTRA_KWH_LEVELS in marginal.py. + # Keys are integers when the HA state cache holds the dict directly; + # defensive fallback to the string form covers any JSON-round-tripped path. + levels = [1, 2, 4, 8] + # Determine the column shape first from the first non-empty row, then + # build all rows against that fixed set of labels so the matrix stays + # rectangular even when lower levels are missing. + time_labels = [] + for lvl in levels: + row = matrix.get(lvl) or matrix.get(str(lvl)) or {} + if isinstance(row, dict) and row: + time_labels = list(row.keys()) + break + + # Only publish once we have something meaningful. + if time_labels: + tmp_costs = [] + for lvl in levels: + row = matrix.get(lvl) or matrix.get(str(lvl)) or {} + if not isinstance(row, dict): + row = {} + # Missing rows or columns are padded with 0 rather than dropped. + tmp_costs.append([round(float(row.get(tl, 0) or 0), 2) for tl in time_labels]) + + marginal_time_labels = time_labels + marginal_costs = tmp_costs + except (TypeError, ValueError, AttributeError, KeyError) as exc: + self.log(f"Warn: GatewayMQTT: failed to read marginal costs: {exc}") + marginal_costs = [] + marginal_time_labels = [] + payload = { "current_price": round(float(current_price), 1), "avg_price": round(float(avg_price or 0), 1), @@ -869,6 +911,8 @@ async def _publish_predbat_data(self): "savings_month_average": saving_month_average, "predbat_status": predbat_status, "predbat_status_detail": predbat_status_detail, + "marginal_costs": marginal_costs, + "marginal_time_labels": marginal_time_labels, } # Only publish if data changed diff --git a/apps/predbat/tests/test_gateway.py b/apps/predbat/tests/test_gateway.py index 19a3f4c8b..658298015 100644 --- a/apps/predbat/tests/test_gateway.py +++ b/apps/predbat/tests/test_gateway.py @@ -1715,6 +1715,142 @@ def test_no_plan_rows_gives_empty_block_and_zero_timeline(self): assert topic == "predbat/devices/pbgw_test/predbat_data" assert retain is True + # ------------------------------------------------------------------ + # Marginal cost matrix + # ------------------------------------------------------------------ + + def test_marginal_costs_nominal_matrix(self): + """Marginal matrix with int keys is flattened in canonical 1/2/4/8 level order.""" + matrix = { + 1: {"14:00": 5.2, "16:00": 4.1, "18:00": 3.8}, + 2: {"14:00": 5.8, "16:00": 4.3, "18:00": 3.9}, + 4: {"14:00": 8.1, "16:00": 7.5, "18:00": 6.8}, + 8: {"14:00": 12.3, "16:00": 11.5, "18:00": 10.8}, + } + gw = self._make_gateway( + { + "predbat.rates": "10.0", + "predbat.cost_today": "0", + "predbat.ppkwh_today": "10.0", + "sensor.predbat_marginal_energy_costs#matrix": matrix, + } + ) + self._run(gw._publish_predbat_data()) + payload = self._get_published_payload(gw) + assert payload["marginal_time_labels"] == ["14:00", "16:00", "18:00"] + assert len(payload["marginal_costs"]) == 4 + assert payload["marginal_costs"][0] == [5.2, 4.1, 3.8] + assert payload["marginal_costs"][3] == [12.3, 11.5, 10.8] + + def test_marginal_costs_string_keys_work(self): + """A JSON-round-tripped matrix with string keys is handled identically.""" + matrix = { + "1": {"14:00": 5.0}, + "2": {"14:00": 6.0}, + "4": {"14:00": 7.0}, + "8": {"14:00": 8.0}, + } + gw = self._make_gateway( + { + "predbat.rates": "10.0", + "predbat.cost_today": "0", + "predbat.ppkwh_today": "10.0", + "sensor.predbat_marginal_energy_costs#matrix": matrix, + } + ) + self._run(gw._publish_predbat_data()) + payload = self._get_published_payload(gw) + assert payload["marginal_costs"] == [[5.0], [6.0], [7.0], [8.0]] + + def test_marginal_costs_missing_sensor_empty_lists(self): + """When the marginal sensor isn't populated the payload still publishes empty lists.""" + gw = self._make_gateway({"predbat.rates": "10.0", "predbat.cost_today": "0", "predbat.ppkwh_today": "10.0"}) + self._run(gw._publish_predbat_data()) + payload = self._get_published_payload(gw) + assert payload["marginal_costs"] == [] + assert payload["marginal_time_labels"] == [] + + def test_marginal_costs_missing_row_padded_with_zeros(self): + """A row missing from the matrix is padded with 0 rather than dropping the whole structure. + + Prevents one absent level collapsing the gateway's view of the matrix. + """ + matrix = { + 1: {"14:00": 5.0, "16:00": 4.0}, + # 2 intentionally missing + 4: {"14:00": 7.0, "16:00": 6.0}, + 8: {"14:00": 9.0, "16:00": 8.0}, + } + gw = self._make_gateway( + { + "predbat.rates": "10.0", + "predbat.cost_today": "0", + "predbat.ppkwh_today": "10.0", + "sensor.predbat_marginal_energy_costs#matrix": matrix, + } + ) + self._run(gw._publish_predbat_data()) + payload = self._get_published_payload(gw) + assert len(payload["marginal_costs"]) == 4 + assert payload["marginal_costs"][0] == [5.0, 4.0] + assert payload["marginal_costs"][1] == [0, 0] # padded + assert payload["marginal_costs"][2] == [7.0, 6.0] + + def test_marginal_costs_leading_missing_rows_padded_with_zeros(self): + """Leading missing rows are zero-padded once later levels define the matrix width.""" + matrix = { + # 1 and 2 intentionally missing + 4: {"14:00": 7.0, "16:00": 6.0}, + 8: {"14:00": 9.0, "16:00": 8.0}, + } + gw = self._make_gateway( + { + "predbat.rates": "10.0", + "predbat.cost_today": "0", + "predbat.ppkwh_today": "10.0", + "sensor.predbat_marginal_energy_costs#matrix": matrix, + } + ) + self._run(gw._publish_predbat_data()) + payload = self._get_published_payload(gw) + assert len(payload["marginal_costs"]) == 4 + assert payload["marginal_costs"][0] == [0, 0] + assert payload["marginal_costs"][1] == [0, 0] + assert payload["marginal_costs"][2] == [7.0, 6.0] + assert payload["marginal_costs"][3] == [9.0, 8.0] + assert payload["marginal_time_labels"] == ["14:00", "16:00"] + + def test_marginal_costs_non_numeric_value_caught(self): + """Non-numeric cells (e.g. 'N/A') don't blow up the publish — graceful empty fallback.""" + matrix = {1: {"14:00": "N/A"}, 2: {"14:00": 0}, 4: {"14:00": 0}, 8: {"14:00": 0}} + gw = self._make_gateway( + { + "predbat.rates": "10.0", + "predbat.cost_today": "0", + "predbat.ppkwh_today": "10.0", + "sensor.predbat_marginal_energy_costs#matrix": matrix, + } + ) + self._run(gw._publish_predbat_data()) + payload = self._get_published_payload(gw) + assert payload["marginal_costs"] == [] + assert payload["marginal_time_labels"] == [] + + def test_marginal_costs_non_dict_matrix_ignored(self): + """Matrix that isn't a dict (e.g. published as a list by mistake) falls back to empty.""" + gw = self._make_gateway( + { + "predbat.rates": "10.0", + "predbat.cost_today": "0", + "predbat.ppkwh_today": "10.0", + "sensor.predbat_marginal_energy_costs#matrix": [1, 2, 3], + } + ) + self._run(gw._publish_predbat_data()) + payload = self._get_published_payload(gw) + assert payload["marginal_costs"] == [] + assert payload["marginal_time_labels"] == [] + class TestIanaToPosixTz: """Tests for GatewayMQTT.iana_to_posix_tz() — IANA to POSIX TZ string conversion."""