Skip to content
44 changes: 44 additions & 0 deletions apps/predbat/gateway.py
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand All @@ -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
Expand Down
136 changes: 136 additions & 0 deletions apps/predbat/tests/test_gateway.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
Comment on lines +1732 to +1736
)
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,
}
Comment on lines +1755 to +1759
)
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,
}
Comment thread
springfall2008 marked this conversation as resolved.
)
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]

Comment thread
springfall2008 marked this conversation as resolved.
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,
}
Comment on lines +1808 to +1812
)
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,
}
Comment on lines +1828 to +1832
)
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],
}
Comment thread
springfall2008 marked this conversation as resolved.
)
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."""
Expand Down
Loading