Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 21 additions & 11 deletions docs/integrations/mqtt-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `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 %
Expand Down Expand Up @@ -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
}
Comment thread
MaStr marked this conversation as resolved.
]
}
```

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

Expand Down
65 changes: 47 additions & 18 deletions src/batcontrol/mqtt_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 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)
Expand Down Expand Up @@ -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
Expand All @@ -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:
Expand All @@ -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
Expand Down
72 changes: 72 additions & 0 deletions tests/batcontrol/test_mqtt_api.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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."""

Expand Down