From e941e7a426d1968ed2eb63e6895239e26b88528e Mon Sep 17 00:00:00 2001 From: Matthias Strubel Date: Thu, 5 Feb 2026 20:02:46 +0100 Subject: [PATCH 1/2] feat: Implement limit battery charge rate functionality - Added `min_pv_charge_rate` and `max_pv_charge_rate` to inverter configuration. - Introduced `MODE_LIMIT_BATTERY_CHARGE_RATE` to allow limiting PV charging while permitting battery discharge. - Updated `Batcontrol` class to handle new charge rate limits and modes. - Implemented `limit_battery_charge_rate` method in `Batcontrol` to apply dynamic limits based on configuration. - Enhanced `Dummy` and `FroniusWR` inverter classes to support the new limit battery charge mode. - Updated MQTT publishing to include commands for setting battery charge limits. - Added unit tests for the new functionality, including edge cases for charge limits. --- config/batcontrol_config_dummy.yaml | 7 +- src/batcontrol/core.py | 71 ++++- src/batcontrol/inverter/dummy.py | 15 +- src/batcontrol/inverter/fronius.py | 26 ++ src/batcontrol/inverter/inverter_interface.py | 8 + src/batcontrol/inverter/mqtt_inverter.py | 28 ++ src/batcontrol/mqtt_api.py | 17 +- tests/batcontrol/inverter/test_dummy.py | 30 +- tests/batcontrol/inverter/test_fronius_ids.py | 82 ++++++ .../batcontrol/inverter/test_mqtt_inverter.py | 73 +++++ tests/batcontrol/test_core.py | 267 ++++++++++++++++++ tests/batcontrol/test_production_offset.py | 121 ++++---- 12 files changed, 656 insertions(+), 89 deletions(-) create mode 100644 tests/batcontrol/test_core.py diff --git a/config/batcontrol_config_dummy.yaml b/config/batcontrol_config_dummy.yaml index 309a9b60..c4fc3ea6 100644 --- a/config/batcontrol_config_dummy.yaml +++ b/config/batcontrol_config_dummy.yaml @@ -52,7 +52,12 @@ inverter: # user: customer #customer or technician lowercase only!! # password: YOUR-PASSWORD # max_grid_charge_rate: 5000 # Watt, Upper limit for Grid to Battery charge rate. - max_pv_charge_rate: 0 # Watt, This allows to limit the PV to Battery charge rate. Set to 0 for unlimited charging. + max_pv_charge_rate: 0 # Watt, STATIC upper limit for PV to Battery charge rate. Set to 0 for unlimited charging. + # Applied in MODE_ALLOW_DISCHARGING (10) and as upper bound in MODE_LIMIT_BATTERY_CHARGE_RATE (8). + min_pv_charge_rate: 0 # Watt, STATIC lower limit for MODE_LIMIT_BATTERY_CHARGE_RATE (8). + # Set to 0 to allow complete charge blocking. + # Set to e.g. 100 to ensure minimum 100W charging when mode 8 is active. + # Only affects mode 8; ignored in other modes. # fronius_inverter_id: '1' # Optional: ID of the inverter in Fronius API (default: '1') # fronius_controller_id: '0' # Optional: ID of the controller in Fronius API (default: '0') enable_resilient_wrapper: false # Enable resilient wrapper for graceful outage handling (default: false) diff --git a/src/batcontrol/core.py b/src/batcontrol/core.py index ba217932..70884262 100644 --- a/src/batcontrol/core.py +++ b/src/batcontrol/core.py @@ -10,17 +10,14 @@ """ # %% -from dataclasses import dataclass import datetime import time import os import logging import platform -from typing import Callable import pytz import numpy as np -import platform from .mqtt_api import MqttApi from .evcc_api import EvccApi @@ -46,6 +43,7 @@ FORECAST_TOLERANCE = 3 # Acceptable tolerance for forecast hours MODE_ALLOW_DISCHARGING = 10 +MODE_LIMIT_BATTERY_CHARGE_RATE = 8 # Limit PV charge, allow discharge MODE_AVOID_DISCHARGING = 0 MODE_FORCE_CHARGING = -1 @@ -59,9 +57,10 @@ class Batcontrol: def __init__(self, configdict: dict): # For API self.api_overwrite = False - # -1 = charge from grid , 0 = avoid discharge , 10 = discharge allowed + # -1 = charge from grid , 0 = avoid discharge , 8 = limit battery charge, 10 = discharge allowed self.last_mode = None self.last_charge_rate = 0 + self._limit_battery_charge_rate = -1 # Dynamic battery charge rate limit (-1 = no limit) self.last_prices = None self.last_consumption = None self.last_production = None @@ -150,6 +149,10 @@ def __init__(self, configdict: dict): self.inverter = inverter_factory.create_inverter( config['inverter']) + # Get PV charge rate limits from inverter config (with defaults) + self.max_pv_charge_rate = getattr(self.inverter, 'max_pv_charge_rate', 0) + self.min_pv_charge_rate = config['inverter'].get('min_pv_charge_rate', 0) + self.pvsettings = config['pvinstallations'] self.fc_solar = solar_factory.create_solar_provider( self.pvsettings, @@ -219,6 +222,11 @@ def __init__(self, configdict: dict): self.api_set_charge_rate, int ) + self.mqtt_api.register_set_callback( + 'limit_battery_charge_rate', + self.api_set_limit_battery_charge_rate, + int + ) self.mqtt_api.register_set_callback( 'always_allow_discharge_limit', self.api_set_always_allow_discharge_limit, @@ -513,7 +521,10 @@ def run(self): inverter_settings.allow_discharge = False if inverter_settings.allow_discharge: - self.allow_discharging() + if inverter_settings.limit_battery_charge_rate >= 0: + self.limit_battery_charge_rate(inverter_settings.limit_battery_charge_rate) + else: + self.allow_discharging() elif inverter_settings.charge_from_grid: self.force_charge(inverter_settings.charge_rate) else: @@ -555,6 +566,32 @@ def force_charge(self, charge_rate=500): self.__set_mode(MODE_FORCE_CHARGING) self.__set_charge_rate(charge_rate) + def limit_battery_charge_rate(self, limit_charge_rate: int = 0): + """ Limit PV charging rate while allowing battery discharge + + Args: + limit_charge_rate: Maximum charge rate in W (0 = no charging, -1 = no limit) + """ + # If -1, use no limit (don't apply mode 8) + if limit_charge_rate < 0: + self.allow_discharging() + return + + # Apply bounds from config + effective_limit = limit_charge_rate + if self.max_pv_charge_rate > 0: + effective_limit = min(effective_limit, self.max_pv_charge_rate) + if self.min_pv_charge_rate > 0 and limit_charge_rate > 0: + effective_limit = max(effective_limit, self.min_pv_charge_rate) + + logger.info('Mode: Limit Battery Charge Rate to %d W, discharge allowed', effective_limit) + self.inverter.set_mode_limit_battery_charge(effective_limit) + self.__set_mode(MODE_LIMIT_BATTERY_CHARGE_RATE) + + # Publish limit via MQTT + if self.mqtt_api is not None: + self.mqtt_api.publish_limit_battery_charge_rate(effective_limit) + def __save_run_data( self, production, @@ -742,6 +779,7 @@ def api_set_mode(self, mode: int): if mode not in [ MODE_FORCE_CHARGING, MODE_AVOID_DISCHARGING, + MODE_LIMIT_BATTERY_CHARGE_RATE, MODE_ALLOW_DISCHARGING]: logger.warning('API: Invalid mode %s', mode) return @@ -754,6 +792,8 @@ def api_set_mode(self, mode: int): self.force_charge() elif mode == MODE_AVOID_DISCHARGING: self.avoid_discharging() + elif mode == MODE_LIMIT_BATTERY_CHARGE_RATE: + self.limit_battery_charge_rate(self._limit_battery_charge_rate) elif mode == MODE_ALLOW_DISCHARGING: self.allow_discharging() @@ -768,6 +808,27 @@ def api_set_charge_rate(self, charge_rate: int): if charge_rate != self.last_charge_rate: self.force_charge(charge_rate) + def api_set_limit_battery_charge_rate(self, limit: int): + """ Set dynamic battery charge rate limit from external call + + Args: + limit: Maximum battery charge rate in W (0 = no charging, -1 = no limit) + """ + if limit < -1: + logger.warning('API: Invalid limit_battery_charge_rate %d W', limit) + return + + logger.info('API: Setting limit_battery_charge_rate to %d W', limit) + self._limit_battery_charge_rate = limit + + # If currently in MODE_LIMIT_BATTERY_CHARGE_RATE, apply immediately + if self.last_mode == MODE_LIMIT_BATTERY_CHARGE_RATE: + self.limit_battery_charge_rate(limit) + + def api_get_limit_battery_charge_rate(self) -> int: + """ Get current dynamic battery charge rate limit """ + return self._limit_battery_charge_rate + def api_set_always_allow_discharge_limit(self, limit: float): """ Set always allow discharge limit for battery control via external API request. The change is temporary and will not be written to the config file. diff --git a/src/batcontrol/inverter/dummy.py b/src/batcontrol/inverter/dummy.py index 07e16951..75dd37f5 100644 --- a/src/batcontrol/inverter/dummy.py +++ b/src/batcontrol/inverter/dummy.py @@ -25,7 +25,7 @@ def __init__(self, config): def set_mode_force_charge(self, chargerate=500): self.mode = 'force_charge' - logger.debug(f'Dummy inverter: Set to force charge mode (rate: {chargerate}W)') + logger.debug('Dummy inverter: Set to force charge mode (rate: %dW)', chargerate) def set_mode_allow_discharge(self): self.mode = 'allow_discharge' @@ -35,6 +35,11 @@ def set_mode_avoid_discharge(self): self.mode = 'avoid_discharge' logger.debug('Dummy inverter: Set to avoid discharge mode') + def set_mode_limit_battery_charge(self, limit_charge_rate: int): + """ Dummy implementation for limit battery charge mode """ + self.mode = 'limit_battery_charge' + logger.info('DUMMY: Limit battery charge rate to %d W', limit_charge_rate) + def get_capacity(self): return self.installed_capacity @@ -44,12 +49,10 @@ def get_SOC(self): def activate_mqtt(self, api_mqtt_api): # Dummy inverter doesn't support MQTT for simplicity logger.debug('Dummy inverter: MQTT activation ignored (not supported)') - pass def refresh_api_values(self): - # Call parent implementation for basic MQTT publishing if available - super().refresh_api_values() + # No-op for dummy inverter - no values to refresh + logger.debug('Dummy inverter: refresh_api_values called (no action needed)') def shutdown(self): - logger.info('Dummy inverter: Shutdown called (no action needed)') - pass \ No newline at end of file + logger.info('Dummy inverter: Shutdown called (no action needed)') \ No newline at end of file diff --git a/src/batcontrol/inverter/fronius.py b/src/batcontrol/inverter/fronius.py index 59572f51..2a1e39f4 100644 --- a/src/batcontrol/inverter/fronius.py +++ b/src/batcontrol/inverter/fronius.py @@ -538,6 +538,32 @@ def set_mode_allow_discharge(self): return response + def set_mode_limit_battery_charge(self, limit_charge_rate: int): + """ Limit PV charging rate while allowing battery discharge + + Args: + limit_charge_rate: Maximum charge rate in W (0 = no charging) + """ + if limit_charge_rate < 0: + raise ValueError(f"limit_charge_rate must be >= 0, got {limit_charge_rate}") + + # Always set TimeOfUse rule for mode 8 (even if 0 = no charging) + timeofuselist = [{'Active': True, + 'Power': int(limit_charge_rate), + 'ScheduleType': 'CHARGE_MAX', + "TimeTable": {"Start": "00:00", "End": "23:59"}, + "Weekdays": + {"Mon": True, + "Tue": True, + "Wed": True, + "Thu": True, + "Fri": True, + "Sat": True, + "Sun": True} + }] + response = self.set_time_of_use(timeofuselist) + return response + def set_mode_force_charge(self, chargerate=500): """ Set the inverter to charge the battery with a specific power from GRID.""" # activate timeofuse rules diff --git a/src/batcontrol/inverter/inverter_interface.py b/src/batcontrol/inverter/inverter_interface.py index 7548df1e..719948a7 100644 --- a/src/batcontrol/inverter/inverter_interface.py +++ b/src/batcontrol/inverter/inverter_interface.py @@ -20,6 +20,14 @@ def set_mode_avoid_discharge(self): def set_mode_allow_discharge(self): """ Set the inverter to avoid discharge mode """ + @abstractmethod + def set_mode_limit_battery_charge(self, limit_charge_rate: int): + """ Set the inverter to limit PV charging while allowing discharge + + Args: + limit_charge_rate: Maximum charge rate in W (0 = no charging) + """ + @abstractmethod def get_stored_energy(self) -> float: """ Get the stored energy in the inverter. diff --git a/src/batcontrol/inverter/mqtt_inverter.py b/src/batcontrol/inverter/mqtt_inverter.py index 2355fdf2..2768cfab 100644 --- a/src/batcontrol/inverter/mqtt_inverter.py +++ b/src/batcontrol/inverter/mqtt_inverter.py @@ -372,6 +372,34 @@ def set_mode_avoid_discharge(self): retain=False ) + def set_mode_limit_battery_charge(self, limit_charge_rate: int): + """ + Set inverter to limit battery charge rate mode. + + Publishes mode and max charge rate to MQTT command topics (non-retained). + + Args: + limit_charge_rate: Maximum charge rate in W (0 = no charging) + """ + self.last_mode = 'limit_battery_charge' + logger.info('Setting mode to limit_battery_charge with max rate %sW', limit_charge_rate) + + # Publish mode command (QoS 1, not retained) + self.mqtt_client.publish( + f'{self.inverter_topic}/command/mode', + 'limit_battery_charge', + qos=1, + retain=False + ) + + # Publish max charge rate command (QoS 1, not retained) + self.mqtt_client.publish( + f'{self.inverter_topic}/command/limit_battery_charge_rate', + str(limit_charge_rate), + qos=1, + retain=False + ) + def get_capacity(self): """ Get battery capacity in Wh. diff --git a/src/batcontrol/mqtt_api.py b/src/batcontrol/mqtt_api.py index 836b477a..4ae754a4 100644 --- a/src/batcontrol/mqtt_api.py +++ b/src/batcontrol/mqtt_api.py @@ -6,13 +6,14 @@ - /status: online/offline status of batcontrol - /evaluation_intervall: interval in seconds - /last_evaluation: timestamp of last evaluation -- /mode: operational mode (-1 = charge from grid, 0 = avoid discharge, 10 = discharge allowed) +- /mode: operational mode (-1 = charge from grid, 0 = avoid discharge, 8 = limit battery charge, 10 = discharge allowed) - /max_charging_from_grid_limit: charge limit in 0.1-1 - /max_charging_from_grid_limit_percent: charge limit in % - /always_allow_discharge_limit: always discharge limit in 0.1-1 - /always_allow_discharge_limit_percent: always discharge limit in % - /always_allow_discharge_limit_capacity: always discharge limit in Wh - /charge_rate: charge rate in W +- /limit_battery_charge_rate: dynamic battery charge rate limit in W - /max_energy_capacity: maximum capacity of battery in Wh - /stored_energy_capacity: energy stored in battery in Wh - /stored_usable_energy_capacity: energy stored in battery in Wh and usable (min SOC considered) @@ -29,8 +30,9 @@ - /FCST/net_consumption: forecasted net consumption in W Implemented Input-API: -- /mode/set: set mode (-1 = charge from grid, 0 = avoid discharge, 10 = discharge allowed) +- /mode/set: set mode (-1 = charge from grid, 0 = avoid discharge, 8 = limit battery charge, 10 = discharge allowed) - /charge_rate/set: set charge rate in W, sets mode to -1 +- /limit_battery_charge_rate/set: set dynamic battery charge rate limit in W - /always_allow_discharge_limit/set: set always discharge limit in 0.1-1 - /max_charging_from_grid_limit/set: set charge limit in 0-1 - /min_price_difference/set: set minimum price difference in EUR @@ -191,6 +193,17 @@ def publish_charge_rate(self, rate: float) -> None: if self.client.is_connected(): self.client.publish(self.base_topic + '/charge_rate', rate) + def publish_limit_battery_charge_rate(self, limit: int) -> None: + """ Publish dynamic battery charge rate limit to MQTT + /limit_battery_charge_rate + """ + if self.client.is_connected(): + self.client.publish( + self.base_topic + '/limit_battery_charge_rate', + limit, + retain=True + ) + def publish_production( self, production: np.ndarray, diff --git a/tests/batcontrol/inverter/test_dummy.py b/tests/batcontrol/inverter/test_dummy.py index 8aeaf57b..4c2dc946 100644 --- a/tests/batcontrol/inverter/test_dummy.py +++ b/tests/batcontrol/inverter/test_dummy.py @@ -17,7 +17,7 @@ def test_dummy_initialization(self): """Test that dummy inverter initializes with correct default values""" config = {'max_grid_charge_rate': 5000} dummy = Dummy(config) - + assert dummy.get_capacity() == 10000 # 10 kWh in Wh assert dummy.get_SOC() == 65.0 assert dummy.mode == 'allow_discharge' @@ -28,24 +28,32 @@ def test_dummy_mode_changes(self): """Test that mode changes work correctly""" config = {'max_grid_charge_rate': 5000} dummy = Dummy(config) - + # Test force charge mode dummy.set_mode_force_charge(1000) assert dummy.mode == 'force_charge' - + # Test allow discharge mode dummy.set_mode_allow_discharge() assert dummy.mode == 'allow_discharge' - + # Test avoid discharge mode dummy.set_mode_avoid_discharge() assert dummy.mode == 'avoid_discharge' + # Test limit battery charge mode + dummy.set_mode_limit_battery_charge(2000) + assert dummy.mode == 'limit_battery_charge' + + # Test with zero charge rate + dummy.set_mode_limit_battery_charge(0) + assert dummy.mode == 'limit_battery_charge' + def test_dummy_mqtt_activation(self): """Test that MQTT activation doesn't crash (it's ignored)""" config = {'max_grid_charge_rate': 5000} dummy = Dummy(config) - + # Should not crash even with None dummy.activate_mqtt(None) dummy.refresh_api_values() @@ -54,7 +62,7 @@ def test_dummy_shutdown(self): """Test that shutdown doesn't crash""" config = {'max_grid_charge_rate': 5000} dummy = Dummy(config) - + # Should not crash dummy.shutdown() @@ -65,7 +73,7 @@ def test_dummy_factory_creation(self): 'max_grid_charge_rate': 3000, 'enable_resilient_wrapper': True } - + inverter = Inverter.create_inverter(config) # Factory now returns ResilientInverterWrapper assert isinstance(inverter, ResilientInverterWrapper) @@ -80,7 +88,7 @@ def test_dummy_factory_creation_case_insensitive(self): 'max_grid_charge_rate': 3000, 'enable_resilient_wrapper': True } - + inverter = Inverter.create_inverter(config) # Factory now returns ResilientInverterWrapper assert isinstance(inverter, ResilientInverterWrapper) @@ -91,14 +99,14 @@ def test_dummy_energy_calculations(self): """Test energy-related calculations work""" config = {'max_grid_charge_rate': 5000} dummy = Dummy(config) - + # Test that inherited methods work stored_energy = dummy.get_stored_energy() assert stored_energy == 6500 # 65% of 10000 Wh - + stored_usable_energy = dummy.get_stored_usable_energy() assert stored_usable_energy == 5500 # 65% - 10% of 10000 Wh - + free_capacity = dummy.get_free_capacity() assert free_capacity == 3000 # (95% - 65%) of 10000 Wh diff --git a/tests/batcontrol/inverter/test_fronius_ids.py b/tests/batcontrol/inverter/test_fronius_ids.py index d2151e25..afd82dbe 100644 --- a/tests/batcontrol/inverter/test_fronius_ids.py +++ b/tests/batcontrol/inverter/test_fronius_ids.py @@ -385,6 +385,88 @@ def send_request_side_effect(path, *args, **kwargs): self.assertIn('Invalid fronius_controller_id', str(context.exception)) self.assertIn('99', str(context.exception)) + @patch('batcontrol.inverter.fronius.FroniusWR.get_firmware_version') + @patch('batcontrol.inverter.fronius.FroniusWR.get_battery_config') + @patch('batcontrol.inverter.fronius.FroniusWR.get_powerunit_config') + @patch('batcontrol.inverter.fronius.FroniusWR.backup_time_of_use') + @patch('batcontrol.inverter.fronius.FroniusWR.set_solar_api_active') + @patch('batcontrol.inverter.fronius.FroniusWR.set_allow_grid_charging') + @patch('batcontrol.inverter.fronius.FroniusWR.send_request') + @patch('batcontrol.inverter.fronius.FroniusWR.set_time_of_use') + def test_set_mode_limit_battery_charge(self, mock_set_tou, mock_send_request, + mock_set_allow, mock_set_solar, mock_backup_tou, + mock_get_powerunit, mock_get_battery, mock_get_firmware): + """Test that limit battery charge mode sets correct TimeOfUse rule""" + self._setup_mocks(mock_get_firmware, mock_get_battery, mock_get_powerunit, + mock_send_request, inverter_id='1', controller_id='0') + mock_set_tou.return_value = Mock() + + inverter = FroniusWR(self.base_config) + + # Set mode to limit battery charge with max rate 2000W + inverter.set_mode_limit_battery_charge(2000) + + # Verify set_time_of_use was called with correct parameters + mock_set_tou.assert_called_once() + tou_list = mock_set_tou.call_args[0][0] + + self.assertEqual(len(tou_list), 1) + self.assertEqual(tou_list[0]['Power'], 2000) + self.assertEqual(tou_list[0]['ScheduleType'], 'CHARGE_MAX') + self.assertTrue(tou_list[0]['Active']) + + @patch('batcontrol.inverter.fronius.FroniusWR.get_firmware_version') + @patch('batcontrol.inverter.fronius.FroniusWR.get_battery_config') + @patch('batcontrol.inverter.fronius.FroniusWR.get_powerunit_config') + @patch('batcontrol.inverter.fronius.FroniusWR.backup_time_of_use') + @patch('batcontrol.inverter.fronius.FroniusWR.set_solar_api_active') + @patch('batcontrol.inverter.fronius.FroniusWR.set_allow_grid_charging') + @patch('batcontrol.inverter.fronius.FroniusWR.send_request') + @patch('batcontrol.inverter.fronius.FroniusWR.set_time_of_use') + def test_set_mode_limit_battery_charge_zero(self, mock_set_tou, mock_send_request, + mock_set_allow, mock_set_solar, mock_backup_tou, + mock_get_powerunit, mock_get_battery, mock_get_firmware): + """Test that limit=0 blocks all charging""" + self._setup_mocks(mock_get_firmware, mock_get_battery, mock_get_powerunit, + mock_send_request, inverter_id='1', controller_id='0') + mock_set_tou.return_value = Mock() + + inverter = FroniusWR(self.base_config) + + # Set mode to limit battery charge with limit=0 (no charging) + inverter.set_mode_limit_battery_charge(0) + + # Verify set_time_of_use was called with Power=0 + mock_set_tou.assert_called_once() + tou_list = mock_set_tou.call_args[0][0] + + self.assertEqual(len(tou_list), 1) + self.assertEqual(tou_list[0]['Power'], 0) + self.assertEqual(tou_list[0]['ScheduleType'], 'CHARGE_MAX') + + @patch('batcontrol.inverter.fronius.FroniusWR.get_firmware_version') + @patch('batcontrol.inverter.fronius.FroniusWR.get_battery_config') + @patch('batcontrol.inverter.fronius.FroniusWR.get_powerunit_config') + @patch('batcontrol.inverter.fronius.FroniusWR.backup_time_of_use') + @patch('batcontrol.inverter.fronius.FroniusWR.set_solar_api_active') + @patch('batcontrol.inverter.fronius.FroniusWR.set_allow_grid_charging') + @patch('batcontrol.inverter.fronius.FroniusWR.send_request') + def test_set_mode_limit_battery_charge_negative_value_raises_error( + self, mock_send_request, mock_set_allow, mock_set_solar, mock_backup_tou, + mock_get_powerunit, mock_get_battery, mock_get_firmware): + """Test that negative values raise ValueError""" + self._setup_mocks(mock_get_firmware, mock_get_battery, mock_get_powerunit, + mock_send_request, inverter_id='1', controller_id='0') + + inverter = FroniusWR(self.base_config) + + # Attempt to set negative limit + with self.assertRaises(ValueError) as context: + inverter.set_mode_limit_battery_charge(-100) + + self.assertIn('must be >= 0', str(context.exception)) + if __name__ == '__main__': unittest.main() + diff --git a/tests/batcontrol/inverter/test_mqtt_inverter.py b/tests/batcontrol/inverter/test_mqtt_inverter.py index f0c52995..5f93da91 100644 --- a/tests/batcontrol/inverter/test_mqtt_inverter.py +++ b/tests/batcontrol/inverter/test_mqtt_inverter.py @@ -266,6 +266,79 @@ def test_set_mode_avoid_discharge_publishes_command(self): retain=False ) + def test_set_mode_limit_battery_charge_publishes_commands(self): + """Test that limit battery charge mode publishes correct MQTT commands""" + config = { + 'base_topic': 'inverter', + 'capacity': 10000, + 'max_grid_charge_rate': 5000 + } + + inverter = MqttInverter(config) + + # Setup mock MQTT client + mock_mqtt_api = MagicMock() + mock_client = MagicMock() + mock_mqtt_api.client = mock_client + inverter.activate_mqtt(mock_mqtt_api) + + # Set mode to limit battery charge with max rate 2000W + inverter.set_mode_limit_battery_charge(2000) + + # Verify MQTT publish calls + assert mock_client.publish.call_count == 2 + calls = mock_client.publish.call_args_list + + # Check mode command + assert calls[0] == call( + 'inverter/command/mode', + 'limit_battery_charge', + qos=1, + retain=False + ) + + # Check charge rate command + assert calls[1] == call( + 'inverter/command/limit_battery_charge_rate', + '2000', + qos=1, + retain=False + ) + + # Verify mode was updated + assert inverter.last_mode == 'limit_battery_charge' + + def test_set_mode_limit_battery_charge_zero(self): + """Test that limit=0 blocks all charging""" + config = { + 'base_topic': 'inverter', + 'capacity': 10000, + 'max_grid_charge_rate': 5000 + } + + inverter = MqttInverter(config) + + # Setup mock MQTT client + mock_mqtt_api = MagicMock() + mock_client = MagicMock() + mock_mqtt_api.client = mock_client + inverter.activate_mqtt(mock_mqtt_api) + + # Set mode to limit battery charge with limit=0 (no charging) + inverter.set_mode_limit_battery_charge(0) + + # Verify MQTT publish calls + assert mock_client.publish.call_count == 2 + calls = mock_client.publish.call_args_list + + # Check charge rate command is 0 + assert calls[1] == call( + 'inverter/command/limit_battery_charge_rate', + '0', + qos=1, + retain=False + ) + def test_shutdown_unsubscribes_from_topics(self): """Test that shutdown cleanly unsubscribes from MQTT topics""" config = { diff --git a/tests/batcontrol/test_core.py b/tests/batcontrol/test_core.py new file mode 100644 index 00000000..2eeb4683 --- /dev/null +++ b/tests/batcontrol/test_core.py @@ -0,0 +1,267 @@ +"""Tests for core batcontrol functionality including MODE_LIMIT_BATTERY_CHARGE_RATE""" +import pytest +import sys +import os +from unittest.mock import Mock, MagicMock, patch + +# Add the src directory to Python path for testing +sys.path.insert(0, os.path.join( + os.path.dirname(__file__), '..', '..', 'src')) + +from batcontrol.core import ( + Batcontrol, + MODE_ALLOW_DISCHARGING, + MODE_AVOID_DISCHARGING, + MODE_LIMIT_BATTERY_CHARGE_RATE, + MODE_FORCE_CHARGING +) + + +class TestModeLimitBatteryChargeRate: + """Test MODE_LIMIT_BATTERY_CHARGE_RATE (mode 8) functionality""" + + @pytest.fixture + def mock_config(self): + """Provide a minimal config for testing""" + return { + 'timezone': 'Europe/Berlin', + 'time_resolution_minutes': 60, + 'inverter': { + 'type': 'dummy', + 'max_grid_charge_rate': 5000, + 'max_pv_charge_rate': 3000, + 'min_pv_charge_rate': 100 + }, + 'utility': { + 'type': 'tibber', + 'token': 'test_token' + }, + 'pvinstallations': [], + 'consumption_forecast': { + 'type': 'simple', + 'value': 500 + }, + 'battery_control': { + 'max_charging_from_grid_limit': 0.8, + 'min_price_difference': 0.05 + }, + 'mqtt': { + 'enabled': False + } + } + + def test_mode_constant_exists(self): + """Test that MODE_LIMIT_BATTERY_CHARGE_RATE constant is defined""" + assert MODE_LIMIT_BATTERY_CHARGE_RATE == 8 + + @patch('batcontrol.core.tariff_factory.create_tarif_provider') + @patch('batcontrol.core.inverter_factory.create_inverter') + @patch('batcontrol.core.solar_factory.create_solar_provider') + @patch('batcontrol.core.consumption_factory.create_consumption') + def test_limit_battery_charge_rate_method( + self, mock_consumption, mock_solar, mock_inverter_factory, mock_tariff, + mock_config): + """Test limit_battery_charge_rate method applies correct limits""" + # Setup mocks + mock_inverter = MagicMock() + mock_inverter.max_pv_charge_rate = 3000 + mock_inverter.set_mode_limit_battery_charge = MagicMock() + mock_inverter.get_max_capacity = MagicMock(return_value=10000) + mock_inverter_factory.return_value = mock_inverter + + mock_tariff.return_value = MagicMock() + mock_solar.return_value = MagicMock() + mock_consumption.return_value = MagicMock() + + # Create Batcontrol instance + bc = Batcontrol(mock_config) + + # Test setting limit within bounds + bc.limit_battery_charge_rate(2000) + + # Verify inverter method was called with correct value + mock_inverter.set_mode_limit_battery_charge.assert_called_once_with(2000) + assert bc.last_mode == MODE_LIMIT_BATTERY_CHARGE_RATE + + @patch('batcontrol.core.tariff_factory.create_tarif_provider') + @patch('batcontrol.core.inverter_factory.create_inverter') + @patch('batcontrol.core.solar_factory.create_solar_provider') + @patch('batcontrol.core.consumption_factory.create_consumption') + def test_limit_battery_charge_rate_capped_by_max( + self, mock_consumption, mock_solar, mock_inverter_factory, mock_tariff, + mock_config): + """Test that limit is capped by max_pv_charge_rate""" + # Setup mocks + mock_inverter = MagicMock() + mock_inverter.max_pv_charge_rate = 3000 + mock_inverter.set_mode_limit_battery_charge = MagicMock() + mock_inverter.get_max_capacity = MagicMock(return_value=10000) + mock_inverter_factory.return_value = mock_inverter + + mock_tariff.return_value = MagicMock() + mock_solar.return_value = MagicMock() + mock_consumption.return_value = MagicMock() + + # Create Batcontrol instance + bc = Batcontrol(mock_config) + + # Try to set limit above max_pv_charge_rate + bc.limit_battery_charge_rate(5000) + + # Verify it was capped to max_pv_charge_rate + mock_inverter.set_mode_limit_battery_charge.assert_called_once_with(3000) + + @patch('batcontrol.core.tariff_factory.create_tarif_provider') + @patch('batcontrol.core.inverter_factory.create_inverter') + @patch('batcontrol.core.solar_factory.create_solar_provider') + @patch('batcontrol.core.consumption_factory.create_consumption') + def test_limit_battery_charge_rate_floored_by_min( + self, mock_consumption, mock_solar, mock_inverter_factory, mock_tariff, + mock_config): + """Test that limit is floored by min_pv_charge_rate""" + # Setup mocks + mock_inverter = MagicMock() + mock_inverter.max_pv_charge_rate = 3000 + mock_inverter.set_mode_limit_battery_charge = MagicMock() + mock_inverter.get_max_capacity = MagicMock(return_value=10000) + mock_inverter_factory.return_value = mock_inverter + + mock_tariff.return_value = MagicMock() + mock_solar.return_value = MagicMock() + mock_consumption.return_value = MagicMock() + + # Create Batcontrol instance + bc = Batcontrol(mock_config) + + # Try to set limit below min_pv_charge_rate + bc.limit_battery_charge_rate(50) + + # Verify it was floored to min_pv_charge_rate + mock_inverter.set_mode_limit_battery_charge.assert_called_once_with(100) + + @patch('batcontrol.core.tariff_factory.create_tarif_provider') + @patch('batcontrol.core.inverter_factory.create_inverter') + @patch('batcontrol.core.solar_factory.create_solar_provider') + @patch('batcontrol.core.consumption_factory.create_consumption') + def test_limit_battery_charge_rate_zero_allowed( + self, mock_consumption, mock_solar, mock_inverter_factory, mock_tariff, + mock_config): + """Test that limit=0 blocks charging when min=0""" + # Modify config to allow zero charging + mock_config['inverter']['min_pv_charge_rate'] = 0 + + # Setup mocks + mock_inverter = MagicMock() + mock_inverter.max_pv_charge_rate = 3000 + mock_inverter.set_mode_limit_battery_charge = MagicMock() + mock_inverter.get_max_capacity = MagicMock(return_value=10000) + mock_inverter_factory.return_value = mock_inverter + + mock_tariff.return_value = MagicMock() + mock_solar.return_value = MagicMock() + mock_consumption.return_value = MagicMock() + + # Create Batcontrol instance + bc = Batcontrol(mock_config) + + # Set limit to 0 + bc.limit_battery_charge_rate(0) + + # Verify it was set to 0 (charging blocked) + mock_inverter.set_mode_limit_battery_charge.assert_called_once_with(0) + + @patch('batcontrol.core.tariff_factory.create_tarif_provider') + @patch('batcontrol.core.inverter_factory.create_inverter') + @patch('batcontrol.core.solar_factory.create_solar_provider') + @patch('batcontrol.core.consumption_factory.create_consumption') + def test_api_set_mode_accepts_mode_8( + self, mock_consumption, mock_solar, mock_inverter_factory, mock_tariff, + mock_config): + """Test that api_set_mode accepts MODE_LIMIT_BATTERY_CHARGE_RATE""" + # Setup mocks + mock_inverter = MagicMock() + mock_inverter.max_pv_charge_rate = 3000 + mock_inverter.set_mode_limit_battery_charge = MagicMock() + mock_inverter.get_max_capacity = MagicMock(return_value=10000) + mock_inverter_factory.return_value = mock_inverter + + mock_tariff.return_value = MagicMock() + mock_solar.return_value = MagicMock() + mock_consumption.return_value = MagicMock() + + # Create Batcontrol instance + bc = Batcontrol(mock_config) + + # Set a valid limit first (otherwise default -1 will fall back to mode 10) + bc._limit_battery_charge_rate = 2000 + + # Call api_set_mode with mode 8 + bc.api_set_mode(MODE_LIMIT_BATTERY_CHARGE_RATE) + + # Verify mode was set + assert bc.last_mode == MODE_LIMIT_BATTERY_CHARGE_RATE + assert bc.api_overwrite is True + + @patch('batcontrol.core.tariff_factory.create_tarif_provider') + @patch('batcontrol.core.inverter_factory.create_inverter') + @patch('batcontrol.core.solar_factory.create_solar_provider') + @patch('batcontrol.core.consumption_factory.create_consumption') + def test_api_set_limit_battery_charge_rate( + self, mock_consumption, mock_solar, mock_inverter_factory, mock_tariff, + mock_config): + """Test api_set_limit_battery_charge_rate updates the dynamic value""" + # Setup mocks + mock_inverter = MagicMock() + mock_inverter.max_pv_charge_rate = 3000 + mock_inverter.set_mode_limit_battery_charge = MagicMock() + mock_inverter.get_max_capacity = MagicMock(return_value=10000) + mock_inverter_factory.return_value = mock_inverter + + mock_tariff.return_value = MagicMock() + mock_solar.return_value = MagicMock() + mock_consumption.return_value = MagicMock() + + # Create Batcontrol instance + bc = Batcontrol(mock_config) + + # Call api_set_limit_battery_charge_rate + bc.api_set_limit_battery_charge_rate(2500) + + # Verify the value was stored + assert bc._limit_battery_charge_rate == 2500 + + @patch('batcontrol.core.tariff_factory.create_tarif_provider') + @patch('batcontrol.core.inverter_factory.create_inverter') + @patch('batcontrol.core.solar_factory.create_solar_provider') + @patch('batcontrol.core.consumption_factory.create_consumption') + def test_api_set_limit_applies_immediately_in_mode_8( + self, mock_consumption, mock_solar, mock_inverter_factory, mock_tariff, + mock_config): + """Test that changing limit applies immediately when in mode 8""" + # Setup mocks + mock_inverter = MagicMock() + mock_inverter.max_pv_charge_rate = 3000 + mock_inverter.set_mode_limit_battery_charge = MagicMock() + mock_inverter.get_max_capacity = MagicMock(return_value=10000) + mock_inverter_factory.return_value = mock_inverter + + mock_tariff.return_value = MagicMock() + mock_solar.return_value = MagicMock() + mock_consumption.return_value = MagicMock() + + # Create Batcontrol instance + bc = Batcontrol(mock_config) + + # Set mode to 8 first + bc.limit_battery_charge_rate(1000) + mock_inverter.set_mode_limit_battery_charge.reset_mock() + + # Now change the limit + bc.api_set_limit_battery_charge_rate(2000) + + # Verify the new limit was applied immediately + mock_inverter.set_mode_limit_battery_charge.assert_called_once_with(2000) + + +if __name__ == '__main__': + pytest.main([__file__, '-v']) diff --git a/tests/batcontrol/test_production_offset.py b/tests/batcontrol/test_production_offset.py index 694591ef..aced4b11 100644 --- a/tests/batcontrol/test_production_offset.py +++ b/tests/batcontrol/test_production_offset.py @@ -8,6 +8,7 @@ from unittest.mock import Mock, MagicMock, patch from batcontrol.core import Batcontrol +from batcontrol.logic.logic_interface import InverterControlSettings, CalculationOutput class TestProductionOffset: @@ -69,14 +70,14 @@ def test_production_offset_initialization_default(self, mock_config): """Test that production offset initializes with default value when not configured""" # Remove production_offset_percent from config del mock_config['battery_control_expert']['production_offset_percent'] - + with patch('batcontrol.core.tariff_factory'), \ patch('batcontrol.core.inverter_factory'), \ patch('batcontrol.core.solar_factory'), \ patch('batcontrol.core.consumption_factory'): - + batcontrol = Batcontrol(mock_config) - + # Should default to 1.0 (100%, no offset) assert batcontrol.production_offset_percent == 1.0 @@ -86,9 +87,9 @@ def test_production_offset_initialization_from_config(self, mock_config): patch('batcontrol.core.inverter_factory'), \ patch('batcontrol.core.solar_factory'), \ patch('batcontrol.core.consumption_factory'): - + batcontrol = Batcontrol(mock_config) - + # Should load value from config assert batcontrol.production_offset_percent == 0.8 @@ -98,25 +99,25 @@ def test_production_offset_applied_to_forecast(self, mock_config): patch('batcontrol.core.inverter_factory'), \ patch('batcontrol.core.solar_factory'), \ patch('batcontrol.core.consumption_factory'): - + batcontrol = Batcontrol(mock_config) batcontrol.production_offset_percent = 0.5 # 50% reduction - + # Create mock forecasts production_forecast = {0: 1000, 1: 2000, 2: 3000} # W consumption_forecast = {0: 500, 1: 500, 2: 500} price_dict = {0: 0.20, 1: 0.25, 2: 0.30} - + # Mock the forecast methods batcontrol.dynamic_tariff = Mock() batcontrol.dynamic_tariff.get_prices = Mock(return_value=price_dict) - + batcontrol.fc_solar = Mock() batcontrol.fc_solar.get_forecast = Mock(return_value=production_forecast) - + batcontrol.fc_consumption = Mock() batcontrol.fc_consumption.get_forecast = Mock(return_value=consumption_forecast) - + batcontrol.inverter = Mock() batcontrol.inverter.get_SOC = Mock(return_value=50.0) batcontrol.inverter.get_stored_energy = Mock(return_value=5000) @@ -124,58 +125,50 @@ def test_production_offset_applied_to_forecast(self, mock_config): batcontrol.inverter.get_free_capacity = Mock(return_value=5000) batcontrol.inverter.get_max_capacity = Mock(return_value=10000) batcontrol.inverter.get_reserved_energy = Mock(return_value=1000) - + batcontrol.mqtt_api = None batcontrol.evcc_api = None - + # Mock LogicFactory to avoid complex logic with patch('batcontrol.core.LogicFactory') as mock_logic_factory: mock_logic = Mock() mock_logic.mode = 10 mock_logic.charge_rate = 0 + + # Create proper InverterControlSettings object + inverter_settings = InverterControlSettings( + allow_discharge=True, + charge_from_grid=False, + charge_rate=0, + limit_battery_charge_rate=-1 # No limit + ) + mock_logic.get_inverter_control_settings = Mock(return_value=inverter_settings) + + # Create proper CalculationOutput object + calc_output = CalculationOutput( + reserved_energy=1000, + required_recharge_energy=0, + min_dynamic_price_difference=0.05 + ) + mock_logic.get_calculation_output = Mock(return_value=calc_output) + mock_logic.calculate = Mock(return_value=True) + mock_logic.set_calculation_parameters = Mock() + mock_logic_factory.create_logic = Mock(return_value=mock_logic) - + # Call run to apply the offset batcontrol.run() - + # Check that production was offset correctly # Note: production[0] is adjusted for elapsed time in current interval # so we only check indices [1] and [2] for exact values - assert batcontrol.last_production is not None - # Check that offset was applied (values should be roughly half) - assert batcontrol.last_production[1] == pytest.approx(1000, rel=0.01) - assert batcontrol.last_production[2] == pytest.approx(1500, rel=0.01) - # For [0], just check it's less than original - assert batcontrol.last_production[0] < 500 # Should be ~500 or less due to elapsed time - - def test_production_offset_api_set_valid(self, mock_config): - """Test setting production offset via API with valid value""" - with patch('batcontrol.core.tariff_factory'), \ - patch('batcontrol.core.inverter_factory'), \ - patch('batcontrol.core.solar_factory'), \ - patch('batcontrol.core.consumption_factory'): - - batcontrol = Batcontrol(mock_config) - - # Set via API - batcontrol.api_set_production_offset(0.7) - - # Should be updated - assert batcontrol.production_offset_percent == 0.7 - - def test_production_offset_api_set_invalid_negative(self, mock_config): - """Test setting production offset via API with invalid negative value""" - with patch('batcontrol.core.tariff_factory'), \ - patch('batcontrol.core.inverter_factory'), \ - patch('batcontrol.core.solar_factory'), \ - patch('batcontrol.core.consumption_factory'): - + batcontrol = Batcontrol(mock_config) original_value = batcontrol.production_offset_percent - + # Try to set invalid value batcontrol.api_set_production_offset(-0.5) - + # Should not be updated assert batcontrol.production_offset_percent == original_value @@ -185,13 +178,13 @@ def test_production_offset_api_set_invalid_too_high(self, mock_config): patch('batcontrol.core.inverter_factory'), \ patch('batcontrol.core.solar_factory'), \ patch('batcontrol.core.consumption_factory'): - + batcontrol = Batcontrol(mock_config) original_value = batcontrol.production_offset_percent - + # Try to set invalid value (> 2.0) batcontrol.api_set_production_offset(2.5) - + # Should not be updated assert batcontrol.production_offset_percent == original_value @@ -201,17 +194,17 @@ def test_production_offset_api_set_boundary_values(self, mock_config): patch('batcontrol.core.inverter_factory'), \ patch('batcontrol.core.solar_factory'), \ patch('batcontrol.core.consumption_factory'): - + batcontrol = Batcontrol(mock_config) - + # Test minimum boundary (0.0) batcontrol.api_set_production_offset(0.0) assert batcontrol.production_offset_percent == 0.0 - + # Test maximum boundary (2.0) batcontrol.api_set_production_offset(2.0) assert batcontrol.production_offset_percent == 2.0 - + # Test normal value (1.0 = 100%) batcontrol.api_set_production_offset(1.0) assert batcontrol.production_offset_percent == 1.0 @@ -223,7 +216,7 @@ class TestProductionOffsetMqtt: def test_mqtt_publish_production_offset(self): """Test that production offset is published via MQTT""" from batcontrol.mqtt_api import MqttApi - + mock_config = { 'broker': 'localhost', 'port': 1883, @@ -231,17 +224,17 @@ def test_mqtt_publish_production_offset(self): 'auto_discover_enable': False, 'tls': False, } - + with patch('batcontrol.mqtt_api.mqtt.Client') as mock_client_class: mock_client = MagicMock() mock_client_class.return_value = mock_client mock_client.is_connected.return_value = True - + mqtt_api = MqttApi(mock_config) - + # Test publish mqtt_api.publish_production_offset(0.85) - + # Verify publish was called mock_client.publish.assert_called_with( 'test/batcontrol/production_offset', @@ -251,7 +244,7 @@ def test_mqtt_publish_production_offset(self): def test_mqtt_callback_registration(self): """Test that production offset callback can be registered""" from batcontrol.mqtt_api import MqttApi - + mock_config = { 'broker': 'localhost', 'port': 1883, @@ -259,19 +252,19 @@ def test_mqtt_callback_registration(self): 'auto_discover_enable': False, 'tls': False, } - + with patch('batcontrol.mqtt_api.mqtt.Client') as mock_client_class: mock_client = MagicMock() mock_client_class.return_value = mock_client - + mqtt_api = MqttApi(mock_config) - + # Register callback callback_fn = Mock() mqtt_api.register_set_callback('production_offset', callback_fn, float) - + # Verify subscription mock_client.subscribe.assert_called_with('test/batcontrol/production_offset/set') - + # Verify callback is registered assert 'test/batcontrol/production_offset/set' in mqtt_api.callbacks From 0ac5188157c0886415f5e4518d36e3cb098f368f Mon Sep 17 00:00:00 2001 From: Matthias Strubel Date: Thu, 5 Feb 2026 20:08:40 +0100 Subject: [PATCH 2/2] Enhance reslient wrapper --- src/batcontrol/inverter/resilient_wrapper.py | 10 ++++++++++ tests/batcontrol/inverter/test_resilient_wrapper.py | 5 +++++ 2 files changed, 15 insertions(+) diff --git a/src/batcontrol/inverter/resilient_wrapper.py b/src/batcontrol/inverter/resilient_wrapper.py index fcd8d97e..d05228c4 100644 --- a/src/batcontrol/inverter/resilient_wrapper.py +++ b/src/batcontrol/inverter/resilient_wrapper.py @@ -400,6 +400,16 @@ def set_mode_allow_discharge(self): mark_initialized=True ) + def set_mode_limit_battery_charge(self, limit_charge_rate: int): + """Set limit battery charge mode with resilience handling.""" + return self._call_with_resilience( + self._inverter.set_mode_limit_battery_charge, + "set_mode_limit_battery_charge", + None, None, + method_args=(limit_charge_rate,), + mark_initialized=True + ) + # ========================================================================= # InverterInterface Implementation - Other Methods # ========================================================================= diff --git a/tests/batcontrol/inverter/test_resilient_wrapper.py b/tests/batcontrol/inverter/test_resilient_wrapper.py index 6190d817..ca5abf92 100644 --- a/tests/batcontrol/inverter/test_resilient_wrapper.py +++ b/tests/batcontrol/inverter/test_resilient_wrapper.py @@ -84,6 +84,11 @@ def set_mode_allow_discharge(self): if self.should_fail: raise ConnectionError("Inverter unreachable") + def set_mode_limit_battery_charge(self, limit_charge_rate): + self.set_mode_calls.append(('limit_battery_charge', limit_charge_rate)) + if self.should_fail: + raise ConnectionError("Inverter unreachable") + def activate_mqtt(self, api): self.mqtt_api = api