diff --git a/config/batcontrol_config_dummy.yaml b/config/batcontrol_config_dummy.yaml index 1a1c04d6..575a453a 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 2aba98e9..c84528d4 100644 --- a/src/batcontrol/core.py +++ b/src/batcontrol/core.py @@ -30,6 +30,7 @@ from .logic import CalculationInput, CalculationParameters from .logic import CommonLogic from .logic import PeakShavingConfig +from .logic.grid_charge_target import GridChargeTargetConfig from .dynamictariff import DynamicTariff as tariff_factory from .inverter import Inverter as inverter_factory @@ -235,6 +236,13 @@ def __init__(self, configdict: dict): self.batconfig.get('min_grid_charge_soc', None), 'battery_control.min_grid_charge_soc' ) + self.grid_charge_target_config = ( + GridChargeTargetConfig.from_battery_control_config(self.batconfig) + ) + self.grid_charge_target_strategy = self.grid_charge_target_config.strategy + self.grid_charge_forecast_pv_factor = ( + self.grid_charge_target_config.pv_forecast_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,14 +624,16 @@ def run(self): self.peak_shaving_config, enabled=peak_shaving_config_enabled and not evcc_disable_peak_shaving, ) + max_capacity = self.get_max_capacity() calc_parameters = CalculationParameters( self.max_charging_from_grid_limit, self.min_price_difference, self.min_price_difference_rel, - self.get_max_capacity(), + max_capacity, min_grid_charge_soc=self.min_grid_charge_soc, preserve_min_grid_charge_soc=self.preserve_min_grid_charge_soc, peak_shaving=ps_runtime, + grid_charge_target=self.grid_charge_target_config, ) self.last_logic_instance = this_logic_run @@ -643,6 +653,9 @@ def run(self): if self.mqtt_api is not None: self.mqtt_api.publish_min_dynamic_price_diff( calc_output.min_dynamic_price_difference) + if calc_output.effective_min_grid_charge_soc is not None: + self.mqtt_api.publish_effective_min_grid_charge_soc( + calc_output.effective_min_grid_charge_soc) if self.discharge_blocked and not \ self.general_logic.is_discharge_always_allowed_soc(self.get_SOC()): diff --git a/src/batcontrol/logic/default.py b/src/batcontrol/logic/default.py index 0d839b16..cdd556f6 100644 --- a/src/batcontrol/logic/default.py +++ b/src/batcontrol/logic/default.py @@ -8,6 +8,7 @@ from .logic_interface import CalculationOutput, InverterControlSettings from .common import CommonLogic from .decision_logging import GridRechargeDecision, log_grid_recharge_decision +from .grid_charge_target import calculate_effective_min_grid_charge_soc # Minimum remaining time in hours to prevent division by very small numbers # when calculating charge rates. This constant serves as a safety threshold: @@ -65,6 +66,9 @@ def calculate(self, input_data: CalculationInput, calc_timestamp: Optional[datet required_recharge_energy=0.0, min_dynamic_price_difference=0.0 ) + self.calculation_output.effective_min_grid_charge_soc = ( + self.__calculate_effective_min_grid_charge_soc(input_data) + ) self.inverter_control_settings = self.calculate_inverter_mode( input_data, @@ -304,22 +308,26 @@ def __is_discharge_allowed(self, calc_input: CalculationInput, # add_remaining required_energy to reserved_storage reserved_storage += required_energy + forecast_target_active = self.__is_forecast_target_raised() min_grid_charge_soc_active = ( self.calculation_parameters.preserve_min_grid_charge_soc - and reserved_storage > 0 - and self.__has_grid_charge_soc_price_signal( - consumption, - prices, - max_slots, - current_price, - min_dynamic_price_difference + and (reserved_storage > 0 or forecast_target_active) + and ( + forecast_target_active + or self.__has_grid_charge_soc_price_signal( + consumption, + prices, + max_slots, + current_price, + min_dynamic_price_difference + ) ) ) reserved_storage = self.common.apply_min_grid_charge_soc_reserve( reserved_storage, calc_input.stored_energy, calc_input.stored_usable_energy, - self.calculation_parameters.min_grid_charge_soc, + self.calculation_output.effective_min_grid_charge_soc, min_grid_charge_soc_active ) @@ -458,13 +466,16 @@ def __get_required_recharge_energy(self, calc_input: CalculationInput, "[Rule] No additional energy required, because stored energy is sufficient." ) recharge_energy = 0.0 + + target_soc = self.calculation_output.effective_min_grid_charge_soc + if required_energy == 0.0 and not self.__is_forecast_target_raised(): self.calculation_output.required_recharge_energy = recharge_energy return recharge_energy recharge_energy = self.common.apply_min_grid_charge_soc_target( recharge_energy, calc_input.stored_energy, - self.calculation_parameters.min_grid_charge_soc + target_soc ) free_capacity = calc_input.free_capacity @@ -484,6 +495,38 @@ def __get_required_recharge_energy(self, calc_input: CalculationInput, self.calculation_output.required_recharge_energy = recharge_energy return recharge_energy + def __calculate_effective_min_grid_charge_soc( + self, calc_input: CalculationInput) -> Optional[float]: + """Calculate the runtime grid-charge target inside the logic layer.""" + effective_soc = calculate_effective_min_grid_charge_soc( + config=self.calculation_parameters.grid_charge_target, + calc_input=calc_input, + configured_min_grid_charge_soc=( + self.calculation_parameters.min_grid_charge_soc), + max_charging_from_grid_limit=( + self.calculation_parameters.max_charging_from_grid_limit), + max_capacity=self.calculation_parameters.max_capacity, + min_price_difference=self.calculation_parameters.min_price_difference, + min_price_difference_rel=( + self.calculation_parameters.min_price_difference_rel), + ) + if effective_soc != self.calculation_parameters.min_grid_charge_soc: + logger.info( + 'Forecast grid-charge target raised min_grid_charge_soc ' + 'from %.1f%% to %.1f%%', + self.calculation_parameters.min_grid_charge_soc * 100, + effective_soc * 100, + ) + return effective_soc + + def __is_forecast_target_raised(self) -> bool: + """Return True when forecast strategy raised the configured floor.""" + effective_soc = self.calculation_output.effective_min_grid_charge_soc + configured_soc = self.calculation_parameters.min_grid_charge_soc + return (effective_soc is not None + and configured_soc is not None + and effective_soc > configured_soc) + def __calculate_min_dynamic_price_difference(self, price: float) -> float: """ Calculate the dynamic limit for the current price """ return round( diff --git a/src/batcontrol/logic/grid_charge_target.py b/src/batcontrol/logic/grid_charge_target.py new file mode 100644 index 00000000..1fdea182 --- /dev/null +++ b/src/batcontrol/logic/grid_charge_target.py @@ -0,0 +1,185 @@ +"""Effective grid-charge SoC target calculation.""" + +from dataclasses import dataclass +from typing import Optional, Sequence, Any + +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, +) + + +@dataclass(frozen=True) +class GridChargeTargetConfig: + """Configuration for effective grid-charge target calculation.""" + + strategy: str = GRID_CHARGE_TARGET_STRATEGY_FIXED + pv_forecast_factor: float = 1.0 + + @classmethod + def from_battery_control_config(cls, config: dict) -> 'GridChargeTargetConfig': + """Create target strategy configuration from battery_control config.""" + return cls( + strategy=_parse_grid_charge_target_strategy( + config.get( + 'grid_charge_target_strategy', + GRID_CHARGE_TARGET_STRATEGY_FIXED, + ) + ), + pv_forecast_factor=_parse_ratio( + config.get('grid_charge_forecast_pv_factor', 1.0), + 'battery_control.grid_charge_forecast_pv_factor', + ), + ) + + +def _parse_ratio(value, config_key: str) -> float: + """Parse a required 0..1 ratio config value.""" + try: + ratio = float(value) + except (TypeError, ValueError) as exc: + raise ValueError( + f"{config_key} must be numeric between 0 and 1, got {value!r}" + ) from exc + if not 0 <= ratio <= 1: + raise ValueError( + f"{config_key} must be between 0 and 1, got {ratio}" + ) + 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 + + +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_min_grid_charge_soc( + config: GridChargeTargetConfig, + calc_input: Any, + configured_min_grid_charge_soc: Optional[float], + max_charging_from_grid_limit: float, + max_capacity: float, + min_price_difference: float, + min_price_difference_rel: float = 0.0) -> Optional[float]: + """Calculate the effective minimum grid-charge SoC from logic inputs.""" + min_soc_energy = max( + 0.0, + calc_input.stored_energy - calc_input.stored_usable_energy, + ) + return calculate_effective_grid_charge_soc( + strategy=config.strategy, + configured_min_grid_charge_soc=configured_min_grid_charge_soc, + max_charging_from_grid_limit=max_charging_from_grid_limit, + max_capacity=max_capacity, + min_soc_energy=min_soc_energy, + production=calc_input.production, + consumption=calc_input.consumption, + prices=calc_input.prices, + min_price_difference=min_price_difference, + min_price_difference_rel=min_price_difference_rel, + pv_forecast_factor=config.pv_forecast_factor, + ) + + +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/logic/logic_interface.py b/src/batcontrol/logic/logic_interface.py index 0c500074..ab68ec1d 100644 --- a/src/batcontrol/logic/logic_interface.py +++ b/src/batcontrol/logic/logic_interface.py @@ -1,7 +1,7 @@ import logging from abc import ABC, abstractmethod from dataclasses import dataclass, field -from typing import Optional +from typing import Optional, Any import datetime import numpy as np @@ -11,6 +11,12 @@ PEAK_SHAVING_VALID_MODES = ('time', 'price', 'combined') +def _default_grid_charge_target_config(): + """Create default grid-charge target strategy config lazily.""" + from .grid_charge_target import GridChargeTargetConfig # pylint: disable=import-outside-toplevel + return GridChargeTargetConfig() + + @dataclass class PeakShavingConfig: """ Holds peak shaving configuration parameters, initialized from the config dict. @@ -117,6 +123,9 @@ class CalculationParameters: # Peak shaving sub-configuration. evcc may set ``enabled=False`` for a # single calculation cycle via ``dataclasses.replace`` in core.py. peak_shaving: PeakShavingConfig = field(default_factory=PeakShavingConfig) + # Grid-charge target strategy sub-configuration. The concrete config class + # lives in grid_charge_target.py; use a lazy factory to avoid import cycles. + grid_charge_target: Any = field(default_factory=_default_grid_charge_target_config) def __post_init__(self): if self.min_grid_charge_soc is not None: @@ -138,6 +147,7 @@ class CalculationOutput: reserved_energy: float = 0.0 required_recharge_energy: float = 0.0 min_dynamic_price_difference: float = 0.05 + effective_min_grid_charge_soc: Optional[float] = None @dataclass class InverterControlSettings: diff --git a/src/batcontrol/logic/next.py b/src/batcontrol/logic/next.py index 2263b763..9f4f5851 100644 --- a/src/batcontrol/logic/next.py +++ b/src/batcontrol/logic/next.py @@ -21,6 +21,7 @@ from .logic_interface import CalculationOutput, InverterControlSettings from .common import CommonLogic from .decision_logging import GridRechargeDecision, log_grid_recharge_decision +from .grid_charge_target import calculate_effective_min_grid_charge_soc # Minimum remaining time in hours to prevent division by very small numbers # when calculating charge rates. This constant serves as a safety threshold: @@ -84,6 +85,9 @@ def calculate(self, input_data: CalculationInput, required_recharge_energy=0.0, min_dynamic_price_difference=0.0 ) + self.calculation_output.effective_min_grid_charge_soc = ( + self._calculate_effective_min_grid_charge_soc(input_data) + ) self.inverter_control_settings = self.calculate_inverter_mode( input_data, @@ -594,22 +598,26 @@ def _is_discharge_allowed(self, calc_input: CalculationInput, # add_remaining required_energy to reserved_storage reserved_storage += required_energy + forecast_target_active = self._is_forecast_target_raised() min_grid_charge_soc_active = ( self.calculation_parameters.preserve_min_grid_charge_soc - and reserved_storage > 0 - and self._has_grid_charge_soc_price_signal( - consumption, - prices, - max_slots, - current_price, - min_dynamic_price_difference + and (reserved_storage > 0 or forecast_target_active) + and ( + forecast_target_active + or self._has_grid_charge_soc_price_signal( + consumption, + prices, + max_slots, + current_price, + min_dynamic_price_difference + ) ) ) reserved_storage = self.common.apply_min_grid_charge_soc_reserve( reserved_storage, calc_input.stored_energy, calc_input.stored_usable_energy, - self.calculation_parameters.min_grid_charge_soc, + self.calculation_output.effective_min_grid_charge_soc, min_grid_charge_soc_active ) @@ -743,13 +751,16 @@ def _get_required_recharge_energy(self, calc_input: CalculationInput, "[Rule] No additional energy required, because stored energy is sufficient." ) recharge_energy = 0.0 + + target_soc = self.calculation_output.effective_min_grid_charge_soc + if required_energy == 0.0 and not self._is_forecast_target_raised(): self.calculation_output.required_recharge_energy = recharge_energy return recharge_energy recharge_energy = self.common.apply_min_grid_charge_soc_target( recharge_energy, calc_input.stored_energy, - self.calculation_parameters.min_grid_charge_soc + target_soc ) free_capacity = calc_input.free_capacity @@ -767,6 +778,38 @@ def _get_required_recharge_energy(self, calc_input: CalculationInput, self.calculation_output.required_recharge_energy = recharge_energy return recharge_energy + def _calculate_effective_min_grid_charge_soc( + self, calc_input: CalculationInput) -> Optional[float]: + """Calculate the runtime grid-charge target inside the logic layer.""" + effective_soc = calculate_effective_min_grid_charge_soc( + config=self.calculation_parameters.grid_charge_target, + calc_input=calc_input, + configured_min_grid_charge_soc=( + self.calculation_parameters.min_grid_charge_soc), + max_charging_from_grid_limit=( + self.calculation_parameters.max_charging_from_grid_limit), + max_capacity=self.calculation_parameters.max_capacity, + min_price_difference=self.calculation_parameters.min_price_difference, + min_price_difference_rel=( + self.calculation_parameters.min_price_difference_rel), + ) + if effective_soc != self.calculation_parameters.min_grid_charge_soc: + logger.info( + 'Forecast grid-charge target raised min_grid_charge_soc ' + 'from %.1f%% to %.1f%%', + self.calculation_parameters.min_grid_charge_soc * 100, + effective_soc * 100, + ) + return effective_soc + + def _is_forecast_target_raised(self) -> bool: + """Return True when forecast strategy raised the configured floor.""" + effective_soc = self.calculation_output.effective_min_grid_charge_soc + configured_soc = self.calculation_parameters.min_grid_charge_soc + return (effective_soc is not None + and configured_soc is not None + and effective_soc > configured_soc) + def _calculate_min_dynamic_price_difference(self, price: float) -> float: """ Calculate the dynamic limit for the current price """ return round( diff --git a/src/batcontrol/mqtt_api.py b/src/batcontrol/mqtt_api.py index 1c5c0d97..2c7c8899 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/helpers.py b/tests/batcontrol/logic/helpers.py index 1e8eb9ad..15f9a40d 100644 --- a/tests/batcontrol/logic/helpers.py +++ b/tests/batcontrol/logic/helpers.py @@ -4,6 +4,7 @@ import numpy as np from batcontrol.logic.common import CommonLogic +from batcontrol.logic.grid_charge_target import GridChargeTargetConfig from batcontrol.logic.logic_interface import ( CalculationInput, CalculationParameters, @@ -30,7 +31,8 @@ def make_logic(logic_cls, *, charge_rate_multiplier=1.1, always_allow_discharge_limit=0.90, min_charge_energy=100, - peak_shaving_enabled=False): + peak_shaving_enabled=False, + grid_charge_target=None): """Create a logic instance with common scenario defaults. The CommonLogic singleton is reset so each helper call applies the @@ -52,6 +54,7 @@ def make_logic(logic_cls, *, min_grid_charge_soc=min_grid_charge_soc, preserve_min_grid_charge_soc=preserve_min_grid_charge_soc, peak_shaving=PeakShavingConfig(enabled=peak_shaving_enabled), + grid_charge_target=grid_charge_target or GridChargeTargetConfig(), )) return logic 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 00000000..63a83851 --- /dev/null +++ b/tests/batcontrol/logic/test_grid_charge_target.py @@ -0,0 +1,172 @@ +import numpy as np +import pytest + +from batcontrol.logic.grid_charge_target import ( + GridChargeTargetConfig, + calculate_effective_grid_charge_soc, + calculate_effective_min_grid_charge_soc, +) +from batcontrol.logic.logic_interface import CalculationInput + + +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 + + +def test_config_parses_strategy_and_pv_factor(): + config = GridChargeTargetConfig.from_battery_control_config({ + 'grid_charge_target_strategy': ' forecast ', + 'grid_charge_forecast_pv_factor': '0.5', + }) + + assert config.strategy == 'forecast' + assert config.pv_forecast_factor == 0.5 + + +def test_effective_min_grid_charge_soc_resolver_uses_calculation_input(): + config = GridChargeTargetConfig(strategy='forecast', pv_forecast_factor=0.5) + calc_input = CalculationInput( + production=np.array([149, 569, 1488, 2678, 3500, 4000]), + consumption=np.array([547, 731, 3427, 3497, 3700, 500]), + prices=np.array([0.4635, 0.7018, 0.7018, 0.7018, 0.7018, 0.4635]), + stored_energy=870.4, + stored_usable_energy=0.0, + free_capacity=8243.2, + ) + + target = calculate_effective_min_grid_charge_soc( + config=config, + calc_input=calc_input, + configured_min_grid_charge_soc=0.55, + max_charging_from_grid_limit=0.89, + max_capacity=10240, + min_price_difference=0.05, + min_price_difference_rel=0.0, + ) + + assert target == pytest.approx(0.79, abs=0.01) diff --git a/tests/batcontrol/logic/test_grid_charge_target_scenarios.py b/tests/batcontrol/logic/test_grid_charge_target_scenarios.py index 6afdc1df..70ca5e19 100644 --- a/tests/batcontrol/logic/test_grid_charge_target_scenarios.py +++ b/tests/batcontrol/logic/test_grid_charge_target_scenarios.py @@ -1,9 +1,10 @@ -"""Scenario tests for externally supplied grid-charge targets.""" +"""Scenario tests for grid-charge targets.""" import datetime import pytest from batcontrol.logic.default import DefaultLogic +from batcontrol.logic.grid_charge_target import GridChargeTargetConfig from batcontrol.logic.next import NextLogic from .helpers import CHEAP_PRICE, EXPENSIVE_PRICE, make_calc_input, make_logic @@ -27,11 +28,10 @@ def _calculate(logic_cls, min_grid_charge_soc): @pytest.mark.parametrize("logic_cls", [DefaultLogic, NextLogic]) def test_higher_grid_charge_target_requests_more_recharge(logic_cls): - """A caller-provided higher target can prepare for a larger expensive window. + """A higher target can prepare for a larger expensive window. - Dynamic target calculation can stay outside the logic layer: when the - current cheap slot receives a higher min_grid_charge_soc, both logic - implementations request more recharge before the future high-price block. + When the current cheap slot receives a higher min_grid_charge_soc, both + logic implementations request more recharge before the future high-price block. """ low_target = _calculate(logic_cls, min_grid_charge_soc=0.55) high_target = _calculate(logic_cls, min_grid_charge_soc=0.84) @@ -44,3 +44,63 @@ def test_higher_grid_charge_target_requests_more_recharge(logic_cls): assert high_output.reserved_energy > low_output.reserved_energy assert high_output.required_recharge_energy > low_output.required_recharge_energy assert high_result.charge_rate > low_result.charge_rate + + +@pytest.mark.parametrize("logic_cls", [DefaultLogic, NextLogic]) +def test_forecast_grid_charge_strategy_raises_recharge_inside_logic(logic_cls): + """Forecast strategy raises recharge need even when normal net forecast is sunny.""" + logic = make_logic( + logic_cls, + min_grid_charge_soc=0.10, + preserve_min_grid_charge_soc=False, + grid_charge_target=GridChargeTargetConfig( + strategy='forecast', + pv_forecast_factor=0.5, + ), + ) + calc_input = make_calc_input( + production=[0, 5000, 5000, 0], + consumption=[0, 4000, 4000, 0], + prices=[CHEAP_PRICE, EXPENSIVE_PRICE, EXPENSIVE_PRICE, CHEAP_PRICE], + soc=10.0, + ) + + assert logic.calculate(calc_input, datetime.datetime( + 2026, 4, 30, 5, 0, 0, tzinfo=datetime.timezone.utc)) + + result = logic.get_inverter_control_settings() + output = logic.get_calculation_output() + + assert output.effective_min_grid_charge_soc > 0.10 + assert output.required_recharge_energy > 0 + assert result.charge_from_grid is True + + +@pytest.mark.parametrize("logic_cls", [DefaultLogic, NextLogic]) +def test_forecast_grid_charge_strategy_preserves_raised_target(logic_cls): + """Preserve mode uses the forecast-raised target, not only normal net demand.""" + logic = make_logic( + logic_cls, + min_grid_charge_soc=0.10, + preserve_min_grid_charge_soc=True, + grid_charge_target=GridChargeTargetConfig( + strategy='forecast', + pv_forecast_factor=0.5, + ), + ) + calc_input = make_calc_input( + production=[0, 5000, 5000, 0], + consumption=[0, 4000, 4000, 0], + prices=[CHEAP_PRICE, EXPENSIVE_PRICE, EXPENSIVE_PRICE, CHEAP_PRICE], + soc=30.0, + ) + + assert logic.calculate(calc_input, datetime.datetime( + 2026, 4, 30, 5, 0, 0, tzinfo=datetime.timezone.utc)) + + result = logic.get_inverter_control_settings() + output = logic.get_calculation_output() + + assert output.effective_min_grid_charge_soc > 0.10 + assert output.reserved_energy > calc_input.stored_usable_energy + assert result.allow_discharge is False diff --git a/tests/batcontrol/test_core.py b/tests/batcontrol/test_core.py index 4204db39..20471f49 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,212 @@ 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, + effective_min_grid_charge_soc=None, + ) + 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_grid_charge_target_config_to_logic( + self, mock_config, mocker): + mock_config['battery_control'].update({ + 'min_grid_charge_soc': 0.55, + '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}, + production={0: 0, 1: 0}, + consumption={0: 500, 1: 5000}, + ) + + try: + bc.run() + + calc_params = fake_logic.set_calculation_parameters.call_args.args[0] + assert calc_params.min_grid_charge_soc == 0.55 + assert calc_params.grid_charge_target.strategy == 'forecast' + assert calc_params.grid_charge_target.pv_forecast_factor == 0.5 + finally: + bc.shutdown() + + 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}, + ) + fake_logic.get_calculation_output.return_value.effective_min_grid_charge_soc = 0.55 + 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_keeps_configured_floor_when_forecast_strategy_is_enabled( + 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 == 0.55 + assert calc_params.grid_charge_target.strategy == 'forecast' + assert calc_params.grid_charge_target.pv_forecast_factor == 0.5 + 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}, + ) + fake_logic.get_calculation_output.return_value.effective_min_grid_charge_soc = 0.79 + 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 4baa2cb5..a87cd608 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."""