diff --git a/src/batcontrol/core.py b/src/batcontrol/core.py index b318711..279762b 100644 --- a/src/batcontrol/core.py +++ b/src/batcontrol/core.py @@ -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()): @@ -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 + + 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 diff --git a/src/batcontrol/mqtt_api.py b/src/batcontrol/mqtt_api.py index 1c5c0d9..bb75b12 100644 --- a/src/batcontrol/mqtt_api.py +++ b/src/batcontrol/mqtt_api.py @@ -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 @@ -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 + ) + def publish_api_override_active(self, active: bool) -> None: """ Publish whether a temporary API override is currently active. /api_override_active @@ -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 = ( diff --git a/tests/batcontrol/test_mqtt_api.py b/tests/batcontrol/test_mqtt_api.py index 4baa2cb..b4f4e0c 100644 --- a/tests/batcontrol/test_mqtt_api.py +++ b/tests/batcontrol/test_mqtt_api.py @@ -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() diff --git a/tests/batcontrol/test_solar_surplus.py b/tests/batcontrol/test_solar_surplus.py new file mode 100644 index 0000000..34ba09d --- /dev/null +++ b/tests/batcontrol/test_solar_surplus.py @@ -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)