diff --git a/config/batcontrol_config_dummy.yaml b/config/batcontrol_config_dummy.yaml index 1a1c04d..575a453 100644 --- a/config/batcontrol_config_dummy.yaml +++ b/config/batcontrol_config_dummy.yaml @@ -19,6 +19,8 @@ battery_control: always_allow_discharge_limit: 0.90 # 0.00 to 1.00 above this SOC limit using energy from the battery is always allowed max_charging_from_grid_limit: 0.89 # 0.00 to 1.00 charging from the grid is only allowed until this SOC limit # min_grid_charge_soc: 0.55 # optional 0.00 to 1.00 target to preserve/charge before expensive slots + # grid_charge_target_strategy: fixed # fixed = use min_grid_charge_soc unchanged; forecast = raise it from forecasted expensive-slot need + # grid_charge_forecast_pv_factor: 1.0 # forecast strategy only: 0.0 to 1.0 multiplier for PV forecast trust min_recharge_amount: 100 # in Wh, start & minimum amount of energy to recharge the battery #-------------------------- diff --git a/src/batcontrol/core.py b/src/batcontrol/core.py index 2aba98e..8ded5c5 100644 --- a/src/batcontrol/core.py +++ b/src/batcontrol/core.py @@ -30,6 +30,11 @@ from .logic import CalculationInput, CalculationParameters from .logic import CommonLogic from .logic import PeakShavingConfig +from .logic.grid_charge_target import ( + GRID_CHARGE_TARGET_STRATEGIES, + GRID_CHARGE_TARGET_STRATEGY_FIXED, + calculate_effective_grid_charge_soc, +) from .dynamictariff import DynamicTariff as tariff_factory from .inverter import Inverter as inverter_factory @@ -80,6 +85,27 @@ def _parse_optional_ratio(value, config_key: str) -> Optional[float]: return ratio +def _parse_ratio(value, config_key: str) -> float: + """Parse a required 0..1 ratio config value.""" + ratio = _parse_optional_ratio(value, config_key) + if ratio is None: + raise ValueError( + f"{config_key} must be numeric between 0 and 1, got None" + ) + return ratio + + +def _parse_grid_charge_target_strategy(value) -> str: + """Parse the grid-charge target strategy config value.""" + strategy = str(value).strip().lower() + if strategy not in GRID_CHARGE_TARGET_STRATEGIES: + raise ValueError( + f"battery_control.grid_charge_target_strategy must be one of " + f"{GRID_CHARGE_TARGET_STRATEGIES}, got {value!r}" + ) + return strategy + + class Batcontrol: """ Main class for Batcontrol, handles the logic and control of the battery system """ general_logic = None # type: CommonLogic @@ -235,6 +261,16 @@ def __init__(self, configdict: dict): self.batconfig.get('min_grid_charge_soc', None), 'battery_control.min_grid_charge_soc' ) + self.grid_charge_target_strategy = _parse_grid_charge_target_strategy( + self.batconfig.get( + 'grid_charge_target_strategy', + GRID_CHARGE_TARGET_STRATEGY_FIXED, + ) + ) + self.grid_charge_forecast_pv_factor = _parse_ratio( + self.batconfig.get('grid_charge_forecast_pv_factor', 1.0), + 'battery_control.grid_charge_forecast_pv_factor' + ) self.preserve_min_grid_charge_soc = False if (self.min_grid_charge_soc is not None and self.min_grid_charge_soc > self.max_charging_from_grid_limit): @@ -616,12 +652,19 @@ def run(self): self.peak_shaving_config, enabled=peak_shaving_config_enabled and not evcc_disable_peak_shaving, ) + effective_min_grid_charge_soc = self.__calculate_effective_min_grid_charge_soc( + calc_input, + production, + consumption, + prices, + ) + calc_parameters = CalculationParameters( self.max_charging_from_grid_limit, self.min_price_difference, self.min_price_difference_rel, self.get_max_capacity(), - min_grid_charge_soc=self.min_grid_charge_soc, + min_grid_charge_soc=effective_min_grid_charge_soc, preserve_min_grid_charge_soc=self.preserve_min_grid_charge_soc, peak_shaving=ps_runtime, ) @@ -666,6 +709,41 @@ def run(self): else: self.avoid_discharging() + def __calculate_effective_min_grid_charge_soc( + self, + calc_input: CalculationInput, + production, + consumption, + prices) -> Optional[float]: + effective_min_grid_charge_soc = calculate_effective_grid_charge_soc( + strategy=self.grid_charge_target_strategy, + configured_min_grid_charge_soc=self.min_grid_charge_soc, + max_charging_from_grid_limit=self.max_charging_from_grid_limit, + max_capacity=self.get_max_capacity(), + min_soc_energy=max( + 0.0, + calc_input.stored_energy - calc_input.stored_usable_energy, + ), + production=production, + consumption=consumption, + prices=prices, + min_price_difference=self.min_price_difference, + min_price_difference_rel=self.min_price_difference_rel, + pv_forecast_factor=self.grid_charge_forecast_pv_factor, + ) + if effective_min_grid_charge_soc != self.min_grid_charge_soc: + logger.info( + 'Forecast grid-charge target raised min_grid_charge_soc ' + 'from %.1f%% to %.1f%%', + self.min_grid_charge_soc * 100, + effective_min_grid_charge_soc * 100, + ) + if (self.mqtt_api is not None + and effective_min_grid_charge_soc is not None): + self.mqtt_api.publish_effective_min_grid_charge_soc( + effective_min_grid_charge_soc) + return effective_min_grid_charge_soc + def __set_charge_rate(self, charge_rate: int): """ Set charge rate and publish to mqtt """ self.last_charge_rate = charge_rate diff --git a/src/batcontrol/logic/grid_charge_target.py b/src/batcontrol/logic/grid_charge_target.py new file mode 100644 index 0000000..bc7c457 --- /dev/null +++ b/src/batcontrol/logic/grid_charge_target.py @@ -0,0 +1,106 @@ +"""Effective grid-charge SoC target calculation.""" + +from typing import Optional, Sequence + +GRID_CHARGE_TARGET_STRATEGY_FIXED = 'fixed' +GRID_CHARGE_TARGET_STRATEGY_FORECAST = 'forecast' +GRID_CHARGE_TARGET_STRATEGIES = ( + GRID_CHARGE_TARGET_STRATEGY_FIXED, + GRID_CHARGE_TARGET_STRATEGY_FORECAST, +) + + +def _ordered_values(values) -> Sequence[float]: + if isinstance(values, dict): + if not values: + return [] + keys = set(values.keys()) + error_message = ( + "forecast dict values must use consecutive integer " + "indices starting at 0" + ) + if not all(isinstance(index, int) for index in keys): + raise ValueError(error_message) + expected_keys = set(range(max(keys) + 1)) + if keys != expected_keys: + raise ValueError(error_message) + return [values[index] for index in range(max(keys) + 1)] + return list(values) + + +def _calculate_min_dynamic_price_difference( + current_price: float, + min_price_difference: float, + min_price_difference_rel: float) -> float: + return max(min_price_difference, + min_price_difference_rel * abs(current_price)) + + +def calculate_effective_grid_charge_soc( + strategy: str, + configured_min_grid_charge_soc: Optional[float], + max_charging_from_grid_limit: float, + max_capacity: float, + min_soc_energy: float, + production, + consumption, + prices, + min_price_difference: float, + min_price_difference_rel: float = 0.0, + pv_forecast_factor: float = 1.0) -> Optional[float]: + """Calculate the effective minimum grid-charge SoC for this evaluation. + + ``fixed`` returns the configured target unchanged. ``forecast`` treats the + configured target as a floor and raises it when future expensive-slot net + demand implies a higher target. Forecast PV can be discounted with + ``pv_forecast_factor`` to account for uncertain PV ramps. + """ + if configured_min_grid_charge_soc is None: + return None + if strategy not in GRID_CHARGE_TARGET_STRATEGIES: + raise ValueError( + f"grid_charge_target_strategy must be one of " + f"{GRID_CHARGE_TARGET_STRATEGIES}, got '{strategy}'" + ) + if strategy == GRID_CHARGE_TARGET_STRATEGY_FIXED: + return configured_min_grid_charge_soc + if max_capacity <= 0: + raise ValueError("max_capacity must be greater than 0") + if not 0 <= pv_forecast_factor <= 1: + raise ValueError( + "grid_charge_forecast_pv_factor must be between 0 and 1, " + f"got {pv_forecast_factor}" + ) + + production_values = _ordered_values(production) + consumption_values = _ordered_values(consumption) + price_values = _ordered_values(prices) + max_slot = min(len(production_values), len(consumption_values), len(price_values)) + if max_slot < 2: + return configured_min_grid_charge_soc + + current_price = price_values[0] + min_dynamic_price_difference = _calculate_min_dynamic_price_difference( + current_price, + min_price_difference, + min_price_difference_rel, + ) + + # Evaluate until the next price slot that is no more expensive than the + # current slot. This keeps the target tied to the current cheap/economical + # charging window rather than charging for the whole forecast horizon. + for slot in range(1, max_slot): + if price_values[slot] <= current_price: + max_slot = slot + break + + forecast_need = 0.0 + for slot in range(1, max_slot): + if price_values[slot] <= current_price + min_dynamic_price_difference: + continue + discounted_pv = production_values[slot] * pv_forecast_factor + forecast_need += max(0.0, consumption_values[slot] - discounted_pv) + + forecast_target = (min_soc_energy + forecast_need) / max_capacity + forecast_target = min(forecast_target, max_charging_from_grid_limit) + return max(configured_min_grid_charge_soc, forecast_target) diff --git a/src/batcontrol/mqtt_api.py b/src/batcontrol/mqtt_api.py index 1c5c0d9..2c7c889 100644 --- a/src/batcontrol/mqtt_api.py +++ b/src/batcontrol/mqtt_api.py @@ -9,8 +9,10 @@ - /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 % -- /min_grid_charge_soc: optional minimum grid-charge target in 0.0-1.0 -- /min_grid_charge_soc_percent: optional minimum grid-charge target in % +- /min_grid_charge_soc: configured optional minimum grid-charge target in 0.0-1.0 +- /min_grid_charge_soc_percent: configured optional minimum grid-charge target in % +- /effective_min_grid_charge_soc: runtime effective minimum grid-charge target in 0.0-1.0 +- /effective_min_grid_charge_soc_percent: runtime effective minimum grid-charge target 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 @@ -390,7 +392,7 @@ def publish_max_charging_from_grid_limit( ) def publish_min_grid_charge_soc(self, min_grid_charge_soc: float) -> None: - """ Publish the optional minimum grid-charge SoC target to MQTT + """ Publish the configured optional minimum grid-charge SoC target to MQTT /min_grid_charge_soc_percent /min_grid_charge_soc as digit. """ @@ -404,6 +406,22 @@ def publish_min_grid_charge_soc(self, min_grid_charge_soc: float) -> None: f'{min_grid_charge_soc:.2f}' ) + def publish_effective_min_grid_charge_soc( + self, effective_min_grid_charge_soc: float) -> None: + """ Publish the runtime effective minimum grid-charge SoC target to MQTT + /effective_min_grid_charge_soc_percent + /effective_min_grid_charge_soc as digit. + """ + if self.client.is_connected(): + self.client.publish( + self.base_topic + '/effective_min_grid_charge_soc_percent', + f'{effective_min_grid_charge_soc * 100:.0f}' + ) + self.client.publish( + self.base_topic + '/effective_min_grid_charge_soc', + f'{effective_min_grid_charge_soc:.2f}' + ) + def publish_min_price_difference( self, min_price_difference: float) -> None: """ Publish the minimum price difference to MQTT found in config @@ -652,6 +670,16 @@ def send_mqtt_discovery_messages(self) -> None: "/min_grid_charge_soc_percent", entity_category="diagnostic") + self.publish_mqtt_discovery_message( + "Effective Minimum Grid Charge SOC", + "batcontrol_effective_min_grid_charge_soc", + "sensor", + "battery", + "%", + self.base_topic + + "/effective_min_grid_charge_soc_percent", + entity_category="diagnostic") + self.publish_mqtt_discovery_message( "Min Price Difference", "batcontrol_min_price_difference", diff --git a/tests/batcontrol/logic/test_grid_charge_target.py b/tests/batcontrol/logic/test_grid_charge_target.py new file mode 100644 index 0000000..11b4f34 --- /dev/null +++ b/tests/batcontrol/logic/test_grid_charge_target.py @@ -0,0 +1,132 @@ +import pytest + +from batcontrol.logic.grid_charge_target import calculate_effective_grid_charge_soc + + +def _calculate_target(**overrides): + values = { + 'strategy': 'forecast', + 'configured_min_grid_charge_soc': 0.55, + 'max_charging_from_grid_limit': 0.89, + 'max_capacity': 10240, + 'min_soc_energy': 1024, + 'production': [149, 569, 1488, 2678, 3500, 4000], + 'consumption': [547, 731, 3427, 3497, 3700, 500], + 'prices': [0.4635, 0.7018, 0.7018, 0.7018, 0.7018, 0.4635], + 'min_price_difference': 0.05, + 'min_price_difference_rel': 0.0, + 'pv_forecast_factor': 0.5, + } + values.update(overrides) + return calculate_effective_grid_charge_soc(**values) + + +def test_fixed_strategy_returns_configured_target(): + target = _calculate_target(strategy='fixed') + + assert target == 0.55 + + +def test_unset_configured_target_stays_disabled(): + target = _calculate_target(configured_min_grid_charge_soc=None) + + assert target is None + + +def test_sparse_forecast_dict_raises_clear_error(): + with pytest.raises( + ValueError, + match='consecutive integer indices starting at 0'): + _calculate_target(production={0: 0, 2: 0}) + + +def test_forecast_strategy_raises_target_for_slow_morning_pv_ramp(): + target = _calculate_target() + + assert target == pytest.approx(0.81, abs=0.01) + + +def test_lower_pv_forecast_factor_raises_target_for_ramp_uncertainty(): + optimistic_target = _calculate_target(pv_forecast_factor=1.0) + conservative_target = _calculate_target(pv_forecast_factor=0.5) + no_pv_target = _calculate_target(pv_forecast_factor=0.0) + + assert optimistic_target == 0.55 + assert optimistic_target < conservative_target < no_pv_target + assert no_pv_target == 0.89 + + +def test_forecast_strategy_ignores_current_slot_flexible_load(): + target = _calculate_target( + production=[0, 0, 0], + consumption=[20000, 0, 0], + prices=[0.20, 0.30, 0.20], + ) + + assert target == 0.55 + + +def test_forecast_strategy_defers_when_another_cheap_slot_remains(): + target = _calculate_target( + production=[0, 0, 0, 0], + consumption=[0, 0, 9000, 9000], + prices=[0.20, 0.20, 0.50, 0.50], + ) + + assert target == 0.55 + + +def test_forecast_strategy_respects_absolute_min_price_difference(): + ignored_small_spread = _calculate_target( + production=[0, 0, 0], + consumption=[0, 5000, 0], + prices=[0.20, 0.249, 0.20], + min_price_difference=0.05, + min_price_difference_rel=0.0, + ) + included_large_spread = _calculate_target( + production=[0, 0, 0], + consumption=[0, 5000, 0], + prices=[0.20, 0.251, 0.20], + min_price_difference=0.05, + min_price_difference_rel=0.0, + ) + + assert ignored_small_spread == 0.55 + assert included_large_spread == pytest.approx(0.59, abs=0.01) + + +def test_forecast_strategy_uses_relative_min_price_difference_when_larger(): + ignored_by_relative_spread = _calculate_target( + production=[0, 0, 0], + consumption=[0, 5000, 0], + prices=[0.50, 0.59, 0.50], + min_price_difference=0.05, + min_price_difference_rel=0.20, + ) + included_by_relative_spread = _calculate_target( + production=[0, 0, 0], + consumption=[0, 5000, 0], + prices=[0.50, 0.61, 0.50], + min_price_difference=0.05, + min_price_difference_rel=0.20, + ) + + assert ignored_by_relative_spread == 0.55 + assert included_by_relative_spread == pytest.approx(0.59, abs=0.01) + + +def test_forecast_strategy_caps_target_at_grid_charge_limit(): + target = _calculate_target(max_charging_from_grid_limit=0.65) + + assert target == 0.65 + + +def test_forecast_strategy_keeps_configured_floor_when_forecast_need_is_small(): + target = _calculate_target( + production=[0, 3000, 3000], + consumption=[500, 500, 500], + prices=[0.20, 0.30, 0.30], + ) + + assert target == 0.55 diff --git a/tests/batcontrol/test_core.py b/tests/batcontrol/test_core.py index 4204db3..2985b32 100644 --- a/tests/batcontrol/test_core.py +++ b/tests/batcontrol/test_core.py @@ -390,6 +390,56 @@ def test_rejects_invalid_min_grid_charge_soc_config( match='battery_control.min_grid_charge_soc must be numeric'): Batcontrol(mock_config) + def test_accepts_grid_charge_target_strategy_case_insensitively( + self, mock_config, mocker): + mock_config['battery_control']['grid_charge_target_strategy'] = 'Forecast' + self._patch_core_dependencies(mocker) + + bc = Batcontrol(mock_config) + + assert bc.grid_charge_target_strategy == 'forecast' + bc.shutdown() + + def test_accepts_grid_charge_target_strategy_with_whitespace( + self, mock_config, mocker): + mock_config['battery_control']['grid_charge_target_strategy'] = ' forecast ' + self._patch_core_dependencies(mocker) + + bc = Batcontrol(mock_config) + + assert bc.grid_charge_target_strategy == 'forecast' + bc.shutdown() + + def test_rejects_unknown_grid_charge_target_strategy_config( + self, mock_config, mocker): + mock_config['battery_control']['grid_charge_target_strategy'] = 'dynamic' + self._patch_core_dependencies(mocker) + + with pytest.raises( + ValueError, + match='battery_control.grid_charge_target_strategy must be one of'): + Batcontrol(mock_config) + + def test_accepts_grid_charge_forecast_pv_factor_numeric_string_config( + self, mock_config, mocker): + mock_config['battery_control']['grid_charge_forecast_pv_factor'] = '0.75' + self._patch_core_dependencies(mocker) + + bc = Batcontrol(mock_config) + + assert bc.grid_charge_forecast_pv_factor == 0.75 + bc.shutdown() + + def test_rejects_invalid_grid_charge_forecast_pv_factor_config( + self, mock_config, mocker): + mock_config['battery_control']['grid_charge_forecast_pv_factor'] = 1.5 + self._patch_core_dependencies(mocker) + + with pytest.raises( + ValueError, + match='battery_control.grid_charge_forecast_pv_factor'): + Batcontrol(mock_config) + def test_warns_when_min_grid_charge_soc_exceeds_grid_charge_limit( self, mock_config, mocker, caplog): core_module = "batcontrol.core" @@ -468,6 +518,182 @@ def test_run_passes_preserve_min_grid_charge_soc_to_logic( calc_params = fake_logic.set_calculation_parameters.call_args.args[0] assert calc_params.preserve_min_grid_charge_soc is True + def _make_batcontrol_for_grid_charge_target( + self, mock_config, mocker, prices, production, consumption): + core_module = "batcontrol.core" + mock_inverter = mocker.MagicMock() + mock_inverter.max_pv_charge_rate = 3000 + mock_inverter.max_grid_charge_rate = 5000 + mock_inverter.get_max_capacity.return_value = 10240 + mock_inverter.get_SOC.return_value = 8.5 + mock_inverter.get_stored_energy.return_value = 870.4 + mock_inverter.get_stored_usable_energy.return_value = 0.0 + mock_inverter.get_free_capacity.return_value = 8243.2 + + mock_tariff_provider = mocker.MagicMock() + mock_tariff_provider.get_prices.return_value = prices + mock_tariff_provider.refresh_data = mocker.MagicMock() + + mock_solar_provider = mocker.MagicMock() + mock_solar_provider.get_forecast.return_value = production + mock_solar_provider.refresh_data = mocker.MagicMock() + + mock_consumption_provider = mocker.MagicMock() + mock_consumption_provider.get_forecast.return_value = consumption + mock_consumption_provider.refresh_data = mocker.MagicMock() + + fake_logic = mocker.MagicMock() + fake_logic.calculate.return_value = True + fake_logic.get_calculation_output.return_value = mocker.MagicMock( + reserved_energy=0, + required_recharge_energy=0, + min_dynamic_price_difference=0.05, + ) + fake_logic.get_inverter_control_settings.return_value = MagicMock( + allow_discharge=True, + charge_from_grid=False, + charge_rate=0, + limit_battery_charge_rate=-1, + ) + + mocker.patch( + f"{core_module}.tariff_factory.create_tarif_provider", + autospec=True, + return_value=mock_tariff_provider, + ) + mocker.patch( + f"{core_module}.inverter_factory.create_inverter", + autospec=True, + return_value=mock_inverter, + ) + mocker.patch( + f"{core_module}.solar_factory.create_solar_provider", + autospec=True, + return_value=mock_solar_provider, + ) + mocker.patch( + f"{core_module}.consumption_factory.create_consumption", + autospec=True, + return_value=mock_consumption_provider, + ) + mocker.patch( + f"{core_module}.LogicFactory.create_logic", + autospec=True, + return_value=fake_logic, + ) + + return Batcontrol(mock_config), fake_logic + + def test_run_passes_fixed_grid_charge_target_by_default( + self, mock_config, mocker): + mock_config['battery_control']['min_grid_charge_soc'] = 0.55 + bc, fake_logic = self._make_batcontrol_for_grid_charge_target( + mock_config, + mocker, + prices={0: 0.4635, 1: 0.7018, 2: 0.7018}, + production={0: 0, 1: 0, 2: 0}, + consumption={0: 500, 1: 5000, 2: 5000}, + ) + + try: + bc.run() + + calc_params = fake_logic.set_calculation_parameters.call_args.args[0] + assert calc_params.min_grid_charge_soc == 0.55 + finally: + bc.shutdown() + + def test_run_publishes_fixed_grid_charge_target_as_effective_by_default( + self, mock_config, mocker): + mock_config['battery_control']['min_grid_charge_soc'] = 0.55 + bc, _fake_logic = self._make_batcontrol_for_grid_charge_target( + mock_config, + mocker, + prices={0: 0.4635, 1: 0.7018, 2: 0.7018}, + production={0: 0, 1: 0, 2: 0}, + consumption={0: 500, 1: 5000, 2: 5000}, + ) + bc.mqtt_api = mocker.MagicMock() + + try: + bc.run() + + bc.mqtt_api.publish_effective_min_grid_charge_soc.assert_called_once_with( + 0.55) + finally: + bc.shutdown() + + def test_run_skips_effective_grid_charge_target_publish_when_unset( + self, mock_config, mocker): + bc, _fake_logic = self._make_batcontrol_for_grid_charge_target( + mock_config, + mocker, + prices={0: 0.4635, 1: 0.7018, 2: 0.7018}, + production={0: 0, 1: 0, 2: 0}, + consumption={0: 500, 1: 5000, 2: 5000}, + ) + bc.mqtt_api = mocker.MagicMock() + + try: + bc.run() + + bc.mqtt_api.publish_effective_min_grid_charge_soc.assert_not_called() + finally: + bc.shutdown() + + def test_run_passes_forecast_grid_charge_target_to_logic( + self, mock_config, mocker): + mock_config['battery_control'].update({ + 'min_grid_charge_soc': 0.55, + 'max_charging_from_grid_limit': 0.89, + 'grid_charge_target_strategy': 'forecast', + 'grid_charge_forecast_pv_factor': 0.5, + }) + bc, fake_logic = self._make_batcontrol_for_grid_charge_target( + mock_config, + mocker, + prices={0: 0.4635, 1: 0.7018, 2: 0.7018, 3: 0.7018, + 4: 0.7018, 5: 0.4635}, + production={0: 149, 1: 569, 2: 1488, 3: 2678, 4: 3500, 5: 4000}, + consumption={0: 547, 1: 731, 2: 3427, 3: 3497, 4: 3700, 5: 500}, + ) + + try: + bc.run() + + calc_params = fake_logic.set_calculation_parameters.call_args.args[0] + assert calc_params.min_grid_charge_soc == pytest.approx(0.79, abs=0.01) + finally: + bc.shutdown() + + def test_run_publishes_effective_grid_charge_target( + self, mock_config, mocker): + mock_config['battery_control'].update({ + 'min_grid_charge_soc': 0.55, + 'max_charging_from_grid_limit': 0.89, + 'grid_charge_target_strategy': 'forecast', + 'grid_charge_forecast_pv_factor': 0.5, + }) + bc, _fake_logic = self._make_batcontrol_for_grid_charge_target( + mock_config, + mocker, + prices={0: 0.4635, 1: 0.7018, 2: 0.7018, 3: 0.7018, + 4: 0.7018, 5: 0.4635}, + production={0: 149, 1: 569, 2: 1488, 3: 2678, 4: 3500, 5: 4000}, + consumption={0: 547, 1: 731, 2: 3427, 3: 3497, 4: 3700, 5: 500}, + ) + bc.mqtt_api = mocker.MagicMock() + + try: + bc.run() + + published_target = ( + bc.mqtt_api.publish_effective_min_grid_charge_soc.call_args.args[0] + ) + assert published_target == pytest.approx(0.79, abs=0.01) + finally: + bc.shutdown() + def test_run_dispatches_force_charge(self, run_dispatch_setup): bc, mock_inverter, fake_logic = run_dispatch_setup fake_logic.get_inverter_control_settings.return_value = MagicMock( diff --git a/tests/batcontrol/test_mqtt_api.py b/tests/batcontrol/test_mqtt_api.py index 4baa2cb..a87cd60 100644 --- a/tests/batcontrol/test_mqtt_api.py +++ b/tests/batcontrol/test_mqtt_api.py @@ -60,6 +60,9 @@ def _make_publish_stub(): api.publish_min_grid_charge_soc = ( MqttApi.publish_min_grid_charge_soc.__get__(api, MqttApi) ) + api.publish_effective_min_grid_charge_soc = ( + MqttApi.publish_effective_min_grid_charge_soc.__get__(api, MqttApi) + ) return api @@ -179,6 +182,16 @@ def test_publish_min_grid_charge_soc_publishes_ratio_and_percent(self): call('batcontrol/min_grid_charge_soc', '0.55'), ] + def test_publish_effective_min_grid_charge_soc_publishes_ratio_and_percent(self): + api = _make_publish_stub() + + api.publish_effective_min_grid_charge_soc(0.79) + + assert api.client.publish.call_args_list == [ + call('batcontrol/effective_min_grid_charge_soc_percent', '79'), + call('batcontrol/effective_min_grid_charge_soc', '0.79'), + ] + class TestModeDiscovery: """Mode discovery should expose the full externally supported mode model.""" @@ -294,6 +307,24 @@ def test_discovery_includes_min_grid_charge_soc_sensor(self): for call in api.publish_mqtt_discovery_message.call_args_list ) + def test_discovery_includes_effective_min_grid_charge_soc_sensor(self): + api = _make_discovery_stub() + + api.send_mqtt_discovery_messages() + + assert any( + call.args[:3] == ( + 'Effective Minimum Grid Charge SOC', + 'batcontrol_effective_min_grid_charge_soc', + 'sensor', + ) + and call.args[3] == 'battery' + and call.args[4] == '%' + and call.args[5] == 'batcontrol/effective_min_grid_charge_soc_percent' + and call.kwargs['entity_category'] == 'diagnostic' + for call in api.publish_mqtt_discovery_message.call_args_list + ) + class TestPublishControlSource: """Control source should publish to its dedicated state topic."""