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
51 changes: 51 additions & 0 deletions src/batcontrol/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -683,6 +683,11 @@ def run(self):
if self.mqtt_api is not None:
self.mqtt_api.publish_min_dynamic_price_diff(
calc_output.min_dynamic_price_difference)
solar_active, surplus_wh = self._compute_solar_active_and_surplus(
production, consumption, calc_input.free_capacity
)
self.mqtt_api.publish_solar_active(solar_active)
self.mqtt_api.publish_solar_surplus(surplus_wh)

if self.discharge_blocked and not \
self.general_logic.is_discharge_always_allowed_soc(self.get_SOC()):
Expand Down Expand Up @@ -866,6 +871,52 @@ def get_reserved_energy(self) -> float:
""" Returns the reserved energy in Wh from last calculation """
return self.last_reserved_energy

def _compute_solar_active_and_surplus(
self,
production: np.ndarray,
consumption: np.ndarray,
free_capacity: float) -> tuple:
"""Compute solar-active flag and expected surplus energy.

Returns:
solar_active (bool): True iff solar is producing in slot 0
surplus_wh (float): Expected solar overflow in Wh (>0 = WP can run)

When solar is active, surplus is the overflow in the current production
window. Otherwise, surplus is the expected overflow at the end of the
next production window after the battery has bridged consumption until
solar restarts.
"""
net_consumption = consumption - production

# Find start and end of the FIRST production window only
production_start: Optional[int] = None
production_end_current: Optional[int] = None
for i, p in enumerate(production):
if p > 0:
if production_start is None:
production_start = i
production_end_current = i
elif production_start is not None:
break
Comment thread
MaStr marked this conversation as resolved.

solar_active = production_start == 0

if production_start is None:
surplus_wh = 0.0
else:
bridge_wh = max(0.0, float(np.sum(net_consumption[:production_start])))
end_idx = (production_end_current + 1) if production_end_current is not None \
else production_start + 1
solar_net_wh = float(-np.sum(net_consumption[production_start:end_idx]))
surplus_wh = max(0.0, solar_net_wh - free_capacity - bridge_wh)

logger.debug(
'Solar active: %s, surplus: %.1f Wh (free_cap=%.1f Wh)',
solar_active, surplus_wh, free_capacity
)
return solar_active, surplus_wh

def set_stored_energy(self, stored_energy) -> None:
""" Set the stored energy in Wh """
self.last_stored_energy = stored_energy
Expand Down
43 changes: 43 additions & 0 deletions src/batcontrol/mqtt_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@
- /production_offset: production offset percentage (1.0 = 100%, 0.8 = 80%, etc.)
- /api_override_active: bool indicating whether a temporary external/API override is active
- /control_source: source that last selected the current control state (api or optimizer)
- /solar_surplus_wh: expected solar surplus energy in Wh (>0 means usable surplus available)
- /solar_active: bool indicating whether solar is currently producing (slot 0 > 0)

The following statistical arrays are published as JSON arrays:
- /FCST/production: forecasted production in W
Expand Down Expand Up @@ -484,6 +486,29 @@ def publish_production_offset(self, production_offset: float) -> None:
f'{production_offset:.3f}'
)

def publish_solar_surplus(self, surplus_wh: float) -> None:
""" Publish the expected solar surplus energy to MQTT.
/solar_surplus_wh
Positive values indicate surplus beyond battery free capacity (during/before)
or surplus stored energy above expected consumption until next solar (after).
"""
if self.client.is_connected():
self.client.publish(
self.base_topic + '/solar_surplus_wh',
f'{surplus_wh:.1f}'
)

def publish_solar_active(self, active: bool) -> None:
""" Publish whether solar is currently producing.
/solar_active
"""
if self.client.is_connected():
self.client.publish(
self.base_topic + '/solar_active',
str(active).lower(),
retain=True
Comment thread
MaStr marked this conversation as resolved.
)

def publish_api_override_active(self, active: bool) -> None:
""" Publish whether a temporary API override is currently active.
/api_override_active
Expand Down Expand Up @@ -875,6 +900,24 @@ def send_mqtt_discovery_messages(self) -> None:
"/stored_energy_capacity",
entity_category="diagnostic")

self.publish_mqtt_discovery_message(
"Solar Surplus",
"batcontrol_solar_surplus_wh",
"sensor",
"energy",
"Wh",
self.base_topic + "/solar_surplus_wh")

self.publish_mqtt_discovery_message(
"Solar Active",
"batcontrol_solar_active",
"binary_sensor",
None,
None,
self.base_topic + "/solar_active",
entity_category="diagnostic",
value_template="{% if value == 'true' %}ON{% else %}OFF{% endif %}")

def send_mqtt_discovery_for_mode(self) -> None:
""" Publish Home Assistant MQTT Auto Discovery message for mode"""
val_templ = (
Expand Down
59 changes: 59 additions & 0 deletions tests/batcontrol/test_mqtt_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -425,3 +425,62 @@ def test_invalid_mode_keeps_old_value(self):
Batcontrol.api_set_peak_shaving_mode(bc, 'bogus')
assert bc.peak_shaving_config is original
bc.mqtt_api.publish_peak_shaving_mode.assert_not_called()


def _make_solar_publish_stub():
"""Stub for solar surplus / solar_active publish tests."""
api = MagicMock(spec=MqttApi)
api.base_topic = 'batcontrol'
api.client = MagicMock()
api.client.is_connected.return_value = True
api.publish_solar_surplus = MqttApi.publish_solar_surplus.__get__(api, MqttApi)
api.publish_solar_active = MqttApi.publish_solar_active.__get__(api, MqttApi)
return api


class TestPublishSolarSurplus:
def test_publishes_to_correct_topic(self):
api = _make_solar_publish_stub()
api.publish_solar_surplus(1234.5)
api.client.publish.assert_called_once_with(
'batcontrol/solar_surplus_wh', '1234.5'
)

def test_formats_with_one_decimal(self):
api = _make_solar_publish_stub()
api.publish_solar_surplus(999.987)
topic, payload = api.client.publish.call_args[0]
assert payload == '1000.0'

def test_zero_surplus_published(self):
api = _make_solar_publish_stub()
api.publish_solar_surplus(0.0)
_, payload = api.client.publish.call_args[0]
assert payload == '0.0'

def test_skips_publish_when_disconnected(self):
api = _make_solar_publish_stub()
api.client.is_connected.return_value = False
api.publish_solar_surplus(500.0)
api.client.publish.assert_not_called()


class TestPublishSolarActive:
def test_publishes_true_to_correct_topic(self):
api = _make_solar_publish_stub()
api.publish_solar_active(True)
api.client.publish.assert_called_once_with(
'batcontrol/solar_active', 'true', retain=True
)

def test_publishes_false(self):
api = _make_solar_publish_stub()
api.publish_solar_active(False)
_, payload = api.client.publish.call_args[0]
assert payload == 'false'

def test_skips_publish_when_disconnected(self):
api = _make_solar_publish_stub()
api.client.is_connected.return_value = False
api.publish_solar_active(True)
api.client.publish.assert_not_called()
145 changes: 145 additions & 0 deletions tests/batcontrol/test_solar_surplus.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
"""Tests for Batcontrol._compute_solar_active_and_surplus."""
import numpy as np
import pytest
from unittest.mock import MagicMock

from batcontrol.core import Batcontrol


def _make_core(time_resolution=60):
"""Return a minimal stub with only the attributes used by the method."""
stub = MagicMock(spec=Batcontrol)
stub.time_resolution = time_resolution
stub._compute_solar_active_and_surplus = (
Batcontrol._compute_solar_active_and_surplus.__get__(stub, Batcontrol)
)
return stub


def _call(stub, production, consumption, free_cap=0.0):
return stub._compute_solar_active_and_surplus(
np.array(production, dtype=float),
np.array(consumption, dtype=float),
free_cap,
)


class TestSolarActive:
def test_active_when_production_starts_at_slot0(self):
stub = _make_core()
active, _ = _call(stub, [1000, 1500, 500], [400, 400, 400])
assert active is True

def test_inactive_when_production_starts_later(self):
stub = _make_core()
active, _ = _call(stub, [0, 0, 800, 1200, 0], [300, 300, 300, 300, 300])
assert active is False

def test_inactive_when_no_production_at_all(self):
stub = _make_core()
active, _ = _call(stub, [0, 0, 0, 0], [300, 400, 350, 300])
assert active is False

def test_active_when_slot0_producing_even_with_gap_after(self):
stub = _make_core()
active, _ = _call(stub, [800, 0, 600, 0], [300, 300, 300, 300])
assert active is True


class TestSurplusActive:
def test_surplus_zero_when_net_production_fits_in_battery(self):
# net = 1000+1000 = 2000 Wh, free_cap=3000 -> fits, no surplus
stub = _make_core()
_, surplus = _call(stub, [1500, 1500, 0], [500, 500, 500], free_cap=3000.0)
assert surplus == pytest.approx(0.0)

def test_surplus_positive_when_net_production_exceeds_free_capacity(self):
# net = 1000+1000 = 2000 Wh, free_cap=1200 -> surplus=800
stub = _make_core()
_, surplus = _call(stub, [1500, 1500, 0], [500, 500, 0], free_cap=1200.0)
assert surplus == pytest.approx(800.0)

def test_surplus_accounts_for_consumption_in_window(self):
# slot0: +500 net, slot1: -500 net -> total=0, no surplus
stub = _make_core()
_, surplus = _call(stub, [2000, 2000, 0], [1500, 2500, 400], free_cap=0.0)
assert surplus == pytest.approx(0.0)

def test_surplus_never_negative(self):
stub = _make_core()
_, surplus = _call(stub, [100, 100, 0], [800, 800, 800], free_cap=10000.0)
assert surplus == 0.0

def test_active_uses_only_first_production_window(self):
# 48h forecast: today's solar then a long break then tomorrow's solar
# 'during' must NOT include tomorrow's solar (production_end stops at first zero)
stub = _make_core()
today_solar = [1500, 1500] # 2000 Wh net production
night = [0] * 12
tomorrow_solar = [1500, 1500]
production = today_solar + night + tomorrow_solar
consumption = [500] * len(production)
# solar_net = -(500-1500 + 500-1500) = 2000 Wh, free_cap=1200 -> surplus=800
_, surplus = _call(stub, production, consumption, free_cap=1200.0)
assert surplus == pytest.approx(800.0)


class TestSurplusInactive:
def test_surplus_zero_when_no_solar_in_forecast(self):
stub = _make_core()
_, surplus = _call(stub, [0, 0, 0, 0], [500, 500, 500, 500], free_cap=0.0)
assert surplus == pytest.approx(0.0)

def test_surplus_positive_when_solar_overflows(self):
# bridge: 2 slots * 200 Wh = 400 Wh
# solar_net: 2 slots * (1000-200) = 1600 Wh
# surplus = max(0, 1600 - 500 - 400) = 700 Wh
stub = _make_core()
production = [0, 0, 1000, 1000, 0]
consumption = [200, 200, 200, 200, 200]
_, surplus = _call(stub, production, consumption, free_cap=500.0)
assert surplus == pytest.approx(700.0)

def test_surplus_zero_when_solar_fits_in_battery_after_night_discharge(self):
# bridge=400, solar_net=800, free_cap=2000 -> 800-2000-400 < 0 -> surplus=0
stub = _make_core()
production = [0, 0, 1000, 0]
consumption = [200, 200, 200, 200]
_, surplus = _call(stub, production, consumption, free_cap=2000.0)
assert surplus == pytest.approx(0.0)

def test_night_discharge_creates_room_for_solar(self):
# slot0: cons=500 (bridge=500, opens battery room)
# slots1-2: 2000W prod, 500W cons -> solar_net=3000 Wh
# surplus = max(0, 3000 - 2000 - 500) = 500
stub = _make_core()
production = [0, 2000, 2000, 0]
consumption = [500, 500, 500, 500]
_, surplus = _call(stub, production, consumption, free_cap=2000.0)
assert surplus == pytest.approx(500.0)

def test_surplus_never_negative(self):
stub = _make_core()
_, surplus = _call(stub, [0, 0, 100, 0], [500, 500, 500, 500], free_cap=10000.0)
assert surplus == 0.0

def test_inactive_only_uses_first_production_window(self):
# 48h: night, tomorrow solar window (slots 2-3), second night, day-after solar
stub = _make_core()
production = [0, 0, 1000, 1000, 0, 0, 0, 0, 1000, 1000]
consumption = [200] * 10
# bridge=400 (slots 0-1), solar_net=1600 (slots 2-3), free_cap=500
# surplus = max(0, 1600-500-400) = 700 (day-after ignored)
_, surplus = _call(stub, production, consumption, free_cap=500.0)
assert surplus == pytest.approx(700.0)

def test_works_with_15min_resolution(self):
# Arrays are already Wh/slot independent of resolution
stub = _make_core(time_resolution=15)
# 4 slots night (200 Wh each) = 800 bridge
# 4 slots solar 500 Wh prod, 200 Wh cons each = 4 * 300 = 1200 Wh solar_net
# free_cap=0 -> surplus = max(0, 1200 - 0 - 800) = 400
production = [0, 0, 0, 0, 500, 500, 500, 500, 0]
consumption = [200] * 9
_, surplus = _call(stub, production, consumption, free_cap=0.0)
assert surplus == pytest.approx(400.0)