From c9590e5ebc8d7e52cd41b1e2cd8e9a28deff7122 Mon Sep 17 00:00:00 2001 From: Matthias Strubel Date: Thu, 30 Jul 2026 12:11:45 +0200 Subject: [PATCH 1/2] fix: add power_w field to MQTT forecast topics, keep Wh consistent /FCST/production, /FCST/consumption and /FCST/net_consumption were documented as being in W, but the published value was always the raw Wh-per-interval array. That is numerically identical to average power at 60-minute resolution, but only 1/4 of it at 15-minute resolution, making 15-min forecasts look 4x too low to anything reading the topic as Watts (e.g. comparing against a provider's own portal). Keep 'value' as Wh per interval (consistent regardless of interval length) and add a derived 'power_w' field with the average power in W, so consumers get an unambiguous number without needing to know time_resolution_minutes themselves. Co-Authored-By: Claude Sonnet 5 --- docs/integrations/mqtt-api.md | 32 +++++++++----- src/batcontrol/mqtt_api.py | 65 ++++++++++++++++++++-------- tests/batcontrol/test_mqtt_api.py | 72 +++++++++++++++++++++++++++++++ 3 files changed, 140 insertions(+), 29 deletions(-) diff --git a/docs/integrations/mqtt-api.md b/docs/integrations/mqtt-api.md index 5136b8d2..e9e7f9cf 100644 --- a/docs/integrations/mqtt-api.md +++ b/docs/integrations/mqtt-api.md @@ -149,10 +149,13 @@ See [Peak Shaving](../features/peak-shaving.md) for details: - `house/batcontrol/min_dynamic_price_difference` - Dynamic price difference limit in EUR ### Forecasts (JSON Arrays) -- `house/batcontrol/FCST/production` - Forecasted solar production in W -- `house/batcontrol/FCST/consumption` - Forecasted consumption in W +- `house/batcontrol/FCST/production` - Forecasted solar production, Wh per interval (plus average W) +- `house/batcontrol/FCST/consumption` - Forecasted consumption, Wh per interval (plus average W) - `house/batcontrol/FCST/prices` - Forecasted electricity prices in EUR -- `house/batcontrol/FCST/net_consumption` - Forecasted net consumption in W +- `house/batcontrol/FCST/net_consumption` - Forecasted net consumption, Wh per interval (plus average W) + +Each interval is 15 or 60 minutes, depending on `general.time_resolution_minutes` - +see the Forecast Data Format section below. ### Inverter-Specific Topics (per inverter, e.g., inverter 0) - `house/batcontrol/inverters/0/SOC` - Inverter SOC in % @@ -210,22 +213,29 @@ The forecast topics (`/FCST/*`) publish JSON data with the following structure: "data": [ { "time_start": 1696435200, - "value": 2500.5, - "time_end": 1696438800 + "value": 625.1, + "power_w": 2500.5, + "time_end": 1696436100 }, { - "time_start": 1696438800, - "value": 3200.0, - "time_end": 1696442400 + "time_start": 1696436100, + "value": 800.0, + "power_w": 3200.0, + "time_end": 1696436999 } ] } ``` Where: -- `time_start` - Unix timestamp for start of hour -- `time_end` - Unix timestamp for end of hour -- `value` - Forecasted value (W for production/consumption, EUR for prices) +- `time_start` - Unix timestamp for start of the interval +- `time_end` - Unix timestamp for end of the interval (15 or 60 minutes later, + depending on `general.time_resolution_minutes`) +- `value` - Forecasted value for that interval: Wh for production/consumption/net_consumption, + EUR for prices +- `power_w` - Only present for production/consumption/net_consumption: the same + quantity expressed as average power in W (`value / interval_hours`), so it + stays comparable regardless of the configured interval length ## Example Configurations diff --git a/src/batcontrol/mqtt_api.py b/src/batcontrol/mqtt_api.py index ef6bd5ce..e42ea9fb 100644 --- a/src/batcontrol/mqtt_api.py +++ b/src/batcontrol/mqtt_api.py @@ -34,10 +34,14 @@ - /forecast_min_battery_wh: minimum battery level in Wh (above MIN_SOC) over the entire forecast horizon (0 = shortage expected) The following statistical arrays are published as JSON arrays: -- /FCST/production: forecasted production in W -- /FCST/consumption: forecasted consumption in W +- /FCST/production: forecasted production, Wh per interval (plus power_w: average W) +- /FCST/consumption: forecasted consumption, Wh per interval (plus power_w: average W) - /FCST/prices: forecasted price in EUR -- /FCST/net_consumption: forecasted net consumption in W +- /FCST/net_consumption: forecasted net consumption, Wh per interval (plus power_w: average W) + +Note: "interval" is 15 or 60 minutes depending on general.time_resolution_minutes. +The Wh value is energy for that interval; power_w is the same quantity expressed +as average power, so it stays comparable across both interval lengths. Implemented Input-API: - /mode/set: set mode (-1 = charge from grid, 0 = avoid discharge, 8 = limit battery charge, 10 = discharge allowed) @@ -248,35 +252,55 @@ def publish_production( timestamp: float) -> None: """ Publish the production to MQTT /FCST/production - The value is in W and based of solar forecast API. + The value is in Wh per interval, based on the solar forecast API. + Each entry also carries a power_w field (average power in W). The length is the same as used in internal arrays. """ if self.client.is_connected(): self.client.publish( self.base_topic + '/FCST/production', - json.dumps(self._create_forecast(production, timestamp)) + json.dumps(self._create_forecast( + production, timestamp, include_power=True)) ) - def _create_forecast(self, forecast: np.ndarray, timestamp: float) -> dict: + def _energy_to_power(self, energy_wh: np.ndarray) -> np.ndarray: + """ Convert Wh-per-interval energy values to average power in W. + + Numerically this equals the Wh value only when the interval is + 60 minutes; at 15-minute resolution the average power is 4x the + Wh-per-interval value. + """ + return energy_wh * (60 / self.interval_minutes) + + def _create_forecast( + self, + forecast: np.ndarray, + timestamp: float, + include_power: bool = False) -> dict: """ Create a forecast JSON object from a numpy array and a timestamp. Handles both 15-minute and 60-minute intervals based on self.interval_minutes. Timestamps are aligned to the start of the current interval. + 'value' is always Wh per interval; set include_power=True to also add + a power_w field (average power in W) for energy-based forecasts. """ interval_seconds = self.interval_minutes * 60 # Align timestamp to the start of the current interval now = timestamp - (timestamp % interval_seconds) + power = self._energy_to_power(forecast) if include_power else None + data_list = [] for i, value in enumerate(forecast): - data_list.append( - { - 'time_start': now + i * interval_seconds, - 'value': value, - 'time_end': now + (i + 1) * interval_seconds - } - ) + entry = { + 'time_start': now + i * interval_seconds, + 'value': value, + 'time_end': now + (i + 1) * interval_seconds + } + if include_power: + entry['power_w'] = power[i] + data_list.append(entry) data = {'data': data_list} return data @@ -287,14 +311,16 @@ def publish_consumption( timestamp: float) -> None: """ Publish the consumption to MQTT /FCST/consumption - The value is in W and based of load profile and multiplied with - personal yearly consumption. + The value is in Wh per interval, based on load profile and + multiplied with personal yearly consumption. + Each entry also carries a power_w field (average power in W). The length is the same as used in internal arrays. """ if self.client.is_connected(): self.client.publish( self.base_topic + '/FCST/consumption', - json.dumps(self._create_forecast(consumption, timestamp)) + json.dumps(self._create_forecast( + consumption, timestamp, include_power=True)) ) def publish_prices(self, price: np.ndarray, timestamp: float) -> None: @@ -312,15 +338,18 @@ def publish_net_consumption( self, net_consumption: np.ndarray, timestamp: float) -> None: - """ Publish the net consumption in W to MQTT + """ Publish the net consumption to MQTT /FCST/net_consumption + The value is in Wh per interval. Each entry also carries a + power_w field (average power in W). The length is the same as used in internal arrays. This is the difference between production and consumption. """ if self.client.is_connected(): self.client.publish( self.base_topic + '/FCST/net_consumption', - json.dumps(self._create_forecast(net_consumption, timestamp)) + json.dumps(self._create_forecast( + net_consumption, timestamp, include_power=True)) ) def publish_SOC(self, soc: float) -> None: # pylint: disable=invalid-name diff --git a/tests/batcontrol/test_mqtt_api.py b/tests/batcontrol/test_mqtt_api.py index cb10aa09..fffc940d 100644 --- a/tests/batcontrol/test_mqtt_api.py +++ b/tests/batcontrol/test_mqtt_api.py @@ -1,6 +1,8 @@ """Tests for MqttApi._handle_message, focusing on bytes payload decoding.""" +import json from unittest.mock import MagicMock, call, patch +import numpy as np import pytest from batcontrol.core import Batcontrol @@ -197,6 +199,76 @@ def test_publish_effective_min_grid_charge_soc_publishes_ratio_and_percent(self) ] +def _make_forecast_publish_stub(interval_minutes: int): + """Stub for FCST/* forecast publish tests.""" + api = MagicMock(spec=MqttApi) + api.base_topic = 'batcontrol' + api.interval_minutes = interval_minutes + api.client = MagicMock() + api.client.is_connected.return_value = True + api._energy_to_power = MqttApi._energy_to_power.__get__(api, MqttApi) + api._create_forecast = MqttApi._create_forecast.__get__(api, MqttApi) + api.publish_production = MqttApi.publish_production.__get__(api, MqttApi) + api.publish_consumption = MqttApi.publish_consumption.__get__(api, MqttApi) + api.publish_net_consumption = MqttApi.publish_net_consumption.__get__(api, MqttApi) + api.publish_prices = MqttApi.publish_prices.__get__(api, MqttApi) + return api + + +class TestForecastPublishing: + """FCST/production, /consumption and /net_consumption always publish the + raw Wh-per-interval value in 'value' (consistent regardless of interval + length), plus a derived 'power_w' field with the average power in W, so + consumers get an unambiguous, resolution-independent number without + having to know the configured time_resolution_minutes themselves. + """ + + def test_publish_production_keeps_wh_and_adds_power_w_at_15min(self): + api = _make_forecast_publish_stub(interval_minutes=15) + + api.publish_production(np.array([1000.0, 500.0]), timestamp=0.0) + + payload = json.loads(api.client.publish.call_args[0][1]) + assert [entry['value'] for entry in payload['data']] == [1000.0, 500.0] + assert [entry['power_w'] for entry in payload['data']] == [4000.0, 2000.0] + + def test_publish_consumption_keeps_wh_and_adds_power_w_at_15min(self): + api = _make_forecast_publish_stub(interval_minutes=15) + + api.publish_consumption(np.array([250.0]), timestamp=0.0) + + payload = json.loads(api.client.publish.call_args[0][1]) + assert payload['data'][0]['value'] == 250.0 + assert payload['data'][0]['power_w'] == 1000.0 + + def test_publish_net_consumption_keeps_wh_and_adds_power_w_at_15min(self): + api = _make_forecast_publish_stub(interval_minutes=15) + + api.publish_net_consumption(np.array([-100.0]), timestamp=0.0) + + payload = json.loads(api.client.publish.call_args[0][1]) + assert payload['data'][0]['value'] == -100.0 + assert payload['data'][0]['power_w'] == -400.0 + + def test_publish_production_power_w_matches_value_at_60min(self): + api = _make_forecast_publish_stub(interval_minutes=60) + + api.publish_production(np.array([1000.0]), timestamp=0.0) + + payload = json.loads(api.client.publish.call_args[0][1]) + assert payload['data'][0]['value'] == 1000.0 + assert payload['data'][0]['power_w'] == 1000.0 + + def test_publish_prices_have_no_power_w_field(self): + api = _make_forecast_publish_stub(interval_minutes=15) + + api.publish_prices(np.array([0.25]), timestamp=0.0) + + payload = json.loads(api.client.publish.call_args[0][1]) + assert payload['data'][0]['value'] == 0.25 + assert 'power_w' not in payload['data'][0] + + class TestModeDiscovery: """Mode discovery should expose the full externally supported mode model.""" From 097eb7340edafb0823ff410cfa6195ddfa5d55a1 Mon Sep 17 00:00:00 2001 From: Matthias Strubel Date: Thu, 30 Jul 2026 12:18:39 +0200 Subject: [PATCH 2/2] Apply suggestions from code review Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- docs/integrations/mqtt-api.md | 2 +- src/batcontrol/mqtt_api.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/integrations/mqtt-api.md b/docs/integrations/mqtt-api.md index e9e7f9cf..5981209f 100644 --- a/docs/integrations/mqtt-api.md +++ b/docs/integrations/mqtt-api.md @@ -154,7 +154,7 @@ See [Peak Shaving](../features/peak-shaving.md) for details: - `house/batcontrol/FCST/prices` - Forecasted electricity prices in EUR - `house/batcontrol/FCST/net_consumption` - Forecasted net consumption, Wh per interval (plus average W) -Each interval is 15 or 60 minutes, depending on `general.time_resolution_minutes` - +Each interval is 15 or 60 minutes, depending on `time_resolution_minutes` - see the Forecast Data Format section below. ### Inverter-Specific Topics (per inverter, e.g., inverter 0) diff --git a/src/batcontrol/mqtt_api.py b/src/batcontrol/mqtt_api.py index e42ea9fb..02704fad 100644 --- a/src/batcontrol/mqtt_api.py +++ b/src/batcontrol/mqtt_api.py @@ -39,7 +39,7 @@ - /FCST/prices: forecasted price in EUR - /FCST/net_consumption: forecasted net consumption, Wh per interval (plus power_w: average W) -Note: "interval" is 15 or 60 minutes depending on general.time_resolution_minutes. +Note: "interval" is 15 or 60 minutes depending on time_resolution_minutes. The Wh value is energy for that interval; power_w is the same quantity expressed as average power, so it stays comparable across both interval lengths.