diff --git a/config/batcontrol_config_dummy.yaml b/config/batcontrol_config_dummy.yaml index a1393fe8..7b9d99a8 100644 --- a/config/batcontrol_config_dummy.yaml +++ b/config/batcontrol_config_dummy.yaml @@ -19,6 +19,7 @@ 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 = keep it as reserve after expensive slots min_recharge_amount: 100 # in Wh, start & minimum amount of energy to recharge the battery #-------------------------- diff --git a/docs/configuration/batcontrol-configuration.md b/docs/configuration/batcontrol-configuration.md index c8da253a..92a64a4a 100644 --- a/docs/configuration/batcontrol-configuration.md +++ b/docs/configuration/batcontrol-configuration.md @@ -80,6 +80,7 @@ battery_control: always_allow_discharge_limit: 0.90 max_charging_from_grid_limit: 0.89 min_grid_charge_soc: 0.55 # optional: grid-charge to this SoC before expensive slots + grid_charge_target_strategy: fixed # optional: fixed or forecast min_recharge_amount: 100 ``` Details about the Price configuration can be found on [price difference calculation](../features/price-difference-calculation.md) page. @@ -87,7 +88,14 @@ Details about the Price configuration can be found on [price difference calculat ![Picture of different parameters on battery soc](../assets/battery_limits_parameter.png) -`min_grid_charge_soc` is optional. When set as a ratio, for example `0.55`, batcontrol grid-charges toward this target when charging is economical. Leave it unset to keep the default behavior. To also preserve this target as reserved energy during cheap/pre-expensive windows, enable the expert option `preserve_min_grid_charge_soc`. +`min_grid_charge_soc` is optional. When set as a ratio, for example `0.55`, batcontrol grid-charges toward this target when charging is economical. Leave it unset to keep the default behavior. + +`grid_charge_target_strategy` controls how `min_grid_charge_soc` is applied: + +- `fixed` (default): grid-charge toward `min_grid_charge_soc` when charging is economical. +- `forecast`: use existing high-price-slot demand as an additional reserve. The target is roughly `min_grid_charge_soc` energy plus the calculated high-price required energy, capped by `max_charging_from_grid_limit`. + +To also preserve this target as reserved energy during cheap/pre-expensive windows, enable the expert option `preserve_min_grid_charge_soc`. Without that expert option, the strategy affects grid recharge only and does not add extra discharge blocking. If `min_grid_charge_soc` is higher than `max_charging_from_grid_limit`, grid charging cannot reach the configured minimum SoC target. Batcontrol will log a warning in this case; increase `max_charging_from_grid_limit` or lower `min_grid_charge_soc` so the settings correlate. diff --git a/docs/integrations/mqtt-api.md b/docs/integrations/mqtt-api.md index 8960e538..1b2ed756 100644 --- a/docs/integrations/mqtt-api.md +++ b/docs/integrations/mqtt-api.md @@ -109,8 +109,10 @@ See [Forecast Metrics](forecast-metrics.md) for a detailed explanation of these - `house/batcontrol/always_allow_discharge_limit_capacity` - Always discharge limit in Wh - `house/batcontrol/max_charging_from_grid_limit` - Max charging from grid limit (0.0-1.0) - `house/batcontrol/max_charging_from_grid_limit_percent` - Max charging from grid limit in % -- `house/batcontrol/min_grid_charge_soc` - Optional minimum grid-charge target (0.0-1.0) -- `house/batcontrol/min_grid_charge_soc_percent` - Optional minimum grid-charge target in % +- `house/batcontrol/min_grid_charge_soc` - Optional configured minimum grid-charge target (0.0-1.0) +- `house/batcontrol/min_grid_charge_soc_percent` - Optional configured minimum grid-charge target in % +- `house/batcontrol/effective_min_grid_charge_soc` - Runtime effective grid-charge target after strategy calculation (0.0-1.0) +- `house/batcontrol/effective_min_grid_charge_soc_percent` - Runtime effective grid-charge target after strategy calculation in % - `house/batcontrol/production_offset` - Production offset multiplier (`1.0` = 100%, `0.8` = 80%, etc.) ### Peak Shaving diff --git a/src/batcontrol/core.py b/src/batcontrol/core.py index 1480381e..056af25d 100644 --- a/src/batcontrol/core.py +++ b/src/batcontrol/core.py @@ -31,6 +31,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 @@ -261,6 +262,10 @@ 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.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): @@ -698,14 +703,16 @@ def _run_once(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 @@ -740,6 +747,9 @@ def _run_once(self): calc_input.stored_usable_energy, calc_input.free_capacity ) self.mqtt_api.publish_forecast_min_battery(forecast_min_wh) + 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..a072d7dc 100644 --- a/src/batcontrol/logic/default.py +++ b/src/batcontrol/logic/default.py @@ -8,6 +8,10 @@ from .logic_interface import CalculationOutput, InverterControlSettings from .common import CommonLogic from .decision_logging import GridRechargeDecision, log_grid_recharge_decision +from .grid_charge_target import ( + apply_grid_charge_target_to_recharge, + apply_grid_charge_target_to_reserve, +) # Minimum remaining time in hours to prevent division by very small numbers # when calculating charge rates. This constant serves as a safety threshold: @@ -63,7 +67,10 @@ def calculate(self, input_data: CalculationInput, calc_timestamp: Optional[datet self.calculation_output = CalculationOutput( reserved_energy=0.0, required_recharge_energy=0.0, - min_dynamic_price_difference=0.0 + min_dynamic_price_difference=0.0, + effective_min_grid_charge_soc=( + self.calculation_parameters.min_grid_charge_soc + ) ) self.inverter_control_settings = self.calculate_inverter_mode( @@ -315,12 +322,24 @@ def __is_discharge_allowed(self, calc_input: CalculationInput, 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, - min_grid_charge_soc_active + min_soc_energy = max( + 0.0, + calc_input.stored_energy - calc_input.stored_usable_energy, + ) + reserve_target = apply_grid_charge_target_to_reserve( + config=self.calculation_parameters.grid_charge_target, + reserved_energy=reserved_storage, + min_soc_energy=min_soc_energy, + configured_min_grid_charge_soc=( + self.calculation_parameters.min_grid_charge_soc), + max_capacity=self.calculation_parameters.max_capacity, + max_charging_from_grid_limit=( + self.calculation_parameters.max_charging_from_grid_limit), + active=min_grid_charge_soc_active, + ) + reserved_storage = reserve_target.energy + self.calculation_output.effective_min_grid_charge_soc = ( + reserve_target.effective_soc ) self.calculation_output.reserved_energy = reserved_storage @@ -458,13 +477,25 @@ def __get_required_recharge_energy(self, calc_input: CalculationInput, "[Rule] No additional energy required, because stored energy is sufficient." ) recharge_energy = 0.0 + + if required_energy == 0.0: 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 + recharge_target = apply_grid_charge_target_to_recharge( + config=self.calculation_parameters.grid_charge_target, + recharge_energy=recharge_energy, + required_energy=required_energy, + stored_energy=calc_input.stored_energy, + configured_min_grid_charge_soc=( + self.calculation_parameters.min_grid_charge_soc), + max_capacity=self.calculation_parameters.max_capacity, + max_charging_from_grid_limit=( + self.calculation_parameters.max_charging_from_grid_limit), + ) + recharge_energy = recharge_target.energy + self.calculation_output.effective_min_grid_charge_soc = ( + recharge_target.effective_soc ) free_capacity = calc_input.free_capacity diff --git a/src/batcontrol/logic/grid_charge_target.py b/src/batcontrol/logic/grid_charge_target.py new file mode 100644 index 00000000..efaee76d --- /dev/null +++ b/src/batcontrol/logic/grid_charge_target.py @@ -0,0 +1,150 @@ +"""Grid-charge target strategy helpers.""" + +from dataclasses import dataclass +from typing import Optional + +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 grid-charge target calculation.""" + + strategy: str = GRID_CHARGE_TARGET_STRATEGY_FIXED + + @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, + ) + ), + ) + + +@dataclass(frozen=True) +class GridChargeTargetResult: + """Energy adjusted by a grid-charge target and the effective target SoC.""" + + energy: float + effective_soc: Optional[float] + + +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 _validate_strategy(strategy: str) -> 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}'" + ) + + +def _capped_forecast_soc( + configured_min_grid_charge_soc: float, + additional_reserve_energy: float, + max_capacity: float, + max_charging_from_grid_limit: float) -> float: + """Return floor-plus-reserve target SoC capped by the grid-charge limit.""" + if max_capacity <= 0: + raise ValueError("max_capacity must be greater than 0") + target_soc = configured_min_grid_charge_soc + ( + additional_reserve_energy / max_capacity) + return min(target_soc, max_charging_from_grid_limit) + + +def apply_grid_charge_target_to_recharge( + config: GridChargeTargetConfig, + recharge_energy: float, + required_energy: float, + stored_energy: float, + configured_min_grid_charge_soc: Optional[float], + max_capacity: float, + max_charging_from_grid_limit: float) -> GridChargeTargetResult: + """Apply the configured grid-charge target to recharge energy. + + ``fixed`` applies the configured SoC floor: when grid charging is already + required, charge at least up to ``min_grid_charge_soc``. + + ``forecast`` uses the existing high-price-slot ``required_energy`` as the + source of truth and treats ``min_grid_charge_soc`` as the reserve that + should remain after those slots. The target is therefore the configured + floor plus the required high-price energy. + """ + if configured_min_grid_charge_soc is None: + return GridChargeTargetResult(recharge_energy, None) + + _validate_strategy(config.strategy) + effective_soc = configured_min_grid_charge_soc + if required_energy <= 0.0: + return GridChargeTargetResult(recharge_energy, effective_soc) + + if config.strategy == GRID_CHARGE_TARGET_STRATEGY_FORECAST: + effective_soc = _capped_forecast_soc( + configured_min_grid_charge_soc, + required_energy, + max_capacity, + max_charging_from_grid_limit, + ) + + target_energy = max_capacity * effective_soc + soc_recharge_energy = max(0.0, target_energy - stored_energy) + adjusted_recharge_energy = max(recharge_energy, soc_recharge_energy) + + if config.strategy == GRID_CHARGE_TARGET_STRATEGY_FORECAST: + max_grid_charge_energy = max( + 0.0, + max_capacity * max_charging_from_grid_limit - stored_energy, + ) + adjusted_recharge_energy = min( + adjusted_recharge_energy, + max_grid_charge_energy, + ) + + return GridChargeTargetResult(adjusted_recharge_energy, effective_soc) + + +def apply_grid_charge_target_to_reserve( + config: GridChargeTargetConfig, + reserved_energy: float, + min_soc_energy: float, + configured_min_grid_charge_soc: Optional[float], + max_capacity: float, + max_charging_from_grid_limit: float, + active: bool) -> GridChargeTargetResult: + """Apply the configured grid-charge target to protected reserve energy.""" + if configured_min_grid_charge_soc is None or not active: + return GridChargeTargetResult(reserved_energy, configured_min_grid_charge_soc) + + _validate_strategy(config.strategy) + effective_soc = configured_min_grid_charge_soc + + if config.strategy == GRID_CHARGE_TARGET_STRATEGY_FORECAST: + effective_soc = _capped_forecast_soc( + configured_min_grid_charge_soc, + reserved_energy, + max_capacity, + max_charging_from_grid_limit, + ) + + target_energy = max_capacity * effective_soc + target_usable_energy = max(0.0, target_energy - min_soc_energy) + adjusted_reserved_energy = max(reserved_energy, target_usable_energy) + return GridChargeTargetResult(adjusted_reserved_energy, effective_soc) 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..f80ac733 100644 --- a/src/batcontrol/logic/next.py +++ b/src/batcontrol/logic/next.py @@ -21,6 +21,10 @@ from .logic_interface import CalculationOutput, InverterControlSettings from .common import CommonLogic from .decision_logging import GridRechargeDecision, log_grid_recharge_decision +from .grid_charge_target import ( + apply_grid_charge_target_to_recharge, + apply_grid_charge_target_to_reserve, +) # Minimum remaining time in hours to prevent division by very small numbers # when calculating charge rates. This constant serves as a safety threshold: @@ -82,7 +86,9 @@ def calculate(self, input_data: CalculationInput, self.calculation_output = CalculationOutput( reserved_energy=0.0, required_recharge_energy=0.0, - min_dynamic_price_difference=0.0 + min_dynamic_price_difference=0.0, + effective_min_grid_charge_soc=( + self.calculation_parameters.min_grid_charge_soc) ) self.inverter_control_settings = self.calculate_inverter_mode( @@ -605,12 +611,24 @@ def _is_discharge_allowed(self, calc_input: CalculationInput, 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, - min_grid_charge_soc_active + min_soc_energy = max( + 0.0, + calc_input.stored_energy - calc_input.stored_usable_energy, + ) + reserve_target = apply_grid_charge_target_to_reserve( + config=self.calculation_parameters.grid_charge_target, + reserved_energy=reserved_storage, + min_soc_energy=min_soc_energy, + configured_min_grid_charge_soc=( + self.calculation_parameters.min_grid_charge_soc), + max_capacity=self.calculation_parameters.max_capacity, + max_charging_from_grid_limit=( + self.calculation_parameters.max_charging_from_grid_limit), + active=min_grid_charge_soc_active, + ) + reserved_storage = reserve_target.energy + self.calculation_output.effective_min_grid_charge_soc = ( + reserve_target.effective_soc ) self.calculation_output.reserved_energy = reserved_storage @@ -644,7 +662,8 @@ def _is_discharge_allowed(self, calc_input: CalculationInput, return False - def _has_grid_charge_soc_price_signal(self, consumption: np.ndarray, + @staticmethod + def _has_grid_charge_soc_price_signal(consumption: np.ndarray, prices: dict, max_slots: int, current_price: float, @@ -743,14 +762,25 @@ def _get_required_recharge_energy(self, calc_input: CalculationInput, "[Rule] No additional energy required, because stored energy is sufficient." ) recharge_energy = 0.0 + + if required_energy == 0.0: 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 + recharge_target = apply_grid_charge_target_to_recharge( + config=self.calculation_parameters.grid_charge_target, + recharge_energy=recharge_energy, + required_energy=required_energy, + stored_energy=calc_input.stored_energy, + configured_min_grid_charge_soc=( + self.calculation_parameters.min_grid_charge_soc), + max_capacity=self.calculation_parameters.max_capacity, + max_charging_from_grid_limit=( + self.calculation_parameters.max_charging_from_grid_limit), ) + recharge_energy = recharge_target.energy + self.calculation_output.effective_min_grid_charge_soc = ( + recharge_target.effective_soc) free_capacity = calc_input.free_capacity diff --git a/src/batcontrol/mqtt_api.py b/src/batcontrol/mqtt_api.py index 82426eba..18dff45f 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 @@ -394,7 +396,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. """ @@ -408,6 +410,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 @@ -703,6 +721,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..6aba64ff --- /dev/null +++ b/tests/batcontrol/logic/test_grid_charge_target.py @@ -0,0 +1,146 @@ +import pytest + +from batcontrol.logic.grid_charge_target import ( + GridChargeTargetConfig, + apply_grid_charge_target_to_recharge, + apply_grid_charge_target_to_reserve, +) + + +CAPACITY_WH = 10000 + + +def _recharge(**overrides): + values = { + 'config': GridChargeTargetConfig(strategy='forecast'), + 'recharge_energy': 2500.0, + 'required_energy': 3000.0, + 'stored_energy': 2000.0, + 'configured_min_grid_charge_soc': 0.50, + 'max_capacity': CAPACITY_WH, + 'max_charging_from_grid_limit': 0.90, + } + values.update(overrides) + return apply_grid_charge_target_to_recharge(**values) + + +def _reserve(**overrides): + values = { + 'config': GridChargeTargetConfig(strategy='forecast'), + 'reserved_energy': 3000.0, + 'min_soc_energy': 1000.0, + 'configured_min_grid_charge_soc': 0.50, + 'max_capacity': CAPACITY_WH, + 'max_charging_from_grid_limit': 0.90, + 'active': True, + } + values.update(overrides) + return apply_grid_charge_target_to_reserve(**values) + + +def test_config_parses_strategy_case_and_whitespace(): + config = GridChargeTargetConfig.from_battery_control_config({ + 'grid_charge_target_strategy': ' forecast ', + }) + + assert config.strategy == 'forecast' + + +def test_config_defaults_to_fixed_strategy(): + config = GridChargeTargetConfig.from_battery_control_config({}) + + assert config.strategy == 'fixed' + + +def test_config_rejects_unknown_strategy(): + with pytest.raises( + ValueError, + match='battery_control.grid_charge_target_strategy must be one of'): + GridChargeTargetConfig.from_battery_control_config({ + 'grid_charge_target_strategy': 'dynamic', + }) + + +def test_recharge_without_configured_soc_stays_unchanged(): + result = _recharge(configured_min_grid_charge_soc=None) + + assert result.energy == 2500.0 + assert result.effective_soc is None + + +def test_fixed_recharge_uses_configured_soc_floor(): + result = _recharge( + config=GridChargeTargetConfig(strategy='fixed'), + recharge_energy=1000.0, + ) + + assert result.energy == pytest.approx(3000.0) + assert result.effective_soc == 0.50 + + +def test_forecast_recharge_adds_high_price_need_to_soc_floor(): + result = _recharge() + + assert result.energy == pytest.approx(6000.0) + assert result.effective_soc == pytest.approx(0.80) + + +def test_forecast_recharge_keeps_existing_need_when_floor_is_lower(): + result = _recharge( + configured_min_grid_charge_soc=0.05, + recharge_energy=2500.0, + ) + + assert result.energy == pytest.approx(2500.0) + assert result.effective_soc == pytest.approx(0.35) + + +def test_forecast_recharge_caps_target_at_grid_charge_limit(): + result = _recharge(max_charging_from_grid_limit=0.70) + + assert result.energy == pytest.approx(5000.0) + assert result.effective_soc == pytest.approx(0.70) + + +def test_recharge_with_no_high_price_need_does_not_fill_floor(): + result = _recharge( + recharge_energy=0.0, + required_energy=0.0, + stored_energy=2000.0, + ) + + assert result.energy == 0.0 + assert result.effective_soc == 0.50 + + +def test_fixed_reserve_uses_configured_soc_floor(): + result = _reserve(config=GridChargeTargetConfig(strategy='fixed')) + + assert result.energy == pytest.approx(4000.0) + assert result.effective_soc == 0.50 + + +def test_forecast_reserve_adds_high_price_need_to_soc_floor(): + result = _reserve() + + assert result.energy == pytest.approx(7000.0) + assert result.effective_soc == pytest.approx(0.80) + + +def test_forecast_reserve_caps_target_at_grid_charge_limit(): + result = _reserve(max_charging_from_grid_limit=0.70) + + assert result.energy == pytest.approx(6000.0) + assert result.effective_soc == pytest.approx(0.70) + + +def test_inactive_reserve_stays_unchanged(): + result = _reserve(active=False) + + assert result.energy == 3000.0 + assert result.effective_soc == 0.50 + + +def test_invalid_strategy_raises_clear_error(): + with pytest.raises(ValueError, match='grid_charge_target_strategy must be one of'): + _recharge(config=GridChargeTargetConfig(strategy='dynamic')) diff --git a/tests/batcontrol/logic/test_grid_charge_target_scenarios.py b/tests/batcontrol/logic/test_grid_charge_target_scenarios.py index 6afdc1df..de04304e 100644 --- a/tests/batcontrol/logic/test_grid_charge_target_scenarios.py +++ b/tests/batcontrol/logic/test_grid_charge_target_scenarios.py @@ -1,12 +1,20 @@ -"""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 +from .helpers import ( + CAPACITY_WH, + CHEAP_PRICE, + EXPENSIVE_PRICE, + MIN_SOC, + make_calc_input, + make_logic, +) def _calculate(logic_cls, min_grid_charge_soc): @@ -27,11 +35,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 +51,105 @@ 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_strategy_adds_high_price_need_to_soc_floor(logic_cls): + """Forecast target keeps the configured floor after the expensive window.""" + logic = make_logic( + logic_cls, + min_grid_charge_soc=0.55, + max_charging_from_grid_limit=1.0, + preserve_min_grid_charge_soc=False, + grid_charge_target=GridChargeTargetConfig(strategy='forecast'), + ) + calc_input = make_calc_input( + production=[0, 0, 0, 0], + consumption=[0, 2000, 2000, 0], + prices=[CHEAP_PRICE, EXPENSIVE_PRICE, EXPENSIVE_PRICE, CHEAP_PRICE], + soc=20.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() + + high_price_required_energy = 4000.0 + target_energy = CAPACITY_WH * 0.55 + high_price_required_energy + expected_recharge = target_energy - calc_input.stored_energy + 100 + + assert result.charge_from_grid is True + assert output.required_recharge_energy == pytest.approx(expected_recharge) + assert output.effective_min_grid_charge_soc == pytest.approx( + target_energy / CAPACITY_WH) + + +@pytest.mark.parametrize("logic_cls", [DefaultLogic, NextLogic]) +def test_forecast_strategy_does_not_charge_floor_without_high_price_need(logic_cls): + """Forecast strategy does not fill the reserve when no high-price need exists.""" + logic = make_logic( + logic_cls, + min_grid_charge_soc=0.55, + preserve_min_grid_charge_soc=False, + grid_charge_target=GridChargeTargetConfig(strategy='forecast'), + ) + calc_input = make_calc_input( + production=[0, 0, 0], + consumption=[0, 0, 0], + prices=[CHEAP_PRICE, EXPENSIVE_PRICE, CHEAP_PRICE], + soc=20.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 result.charge_from_grid is False + assert output.required_recharge_energy == 0.0 + + +@pytest.mark.parametrize("logic_cls", [DefaultLogic, NextLogic]) +def test_forecast_strategy_preserve_adds_reserve_only_when_enabled(logic_cls): + """Preserve mode protects floor plus high-price need; disabled mode does not.""" + calc_input = make_calc_input( + production=[0, 0, 0, 0], + consumption=[0, 1500, 1500, 0], + prices=[CHEAP_PRICE, EXPENSIVE_PRICE, EXPENSIVE_PRICE, CHEAP_PRICE], + soc=60.0, + ) + disabled_logic = make_logic( + logic_cls, + min_grid_charge_soc=0.55, + preserve_min_grid_charge_soc=False, + grid_charge_target=GridChargeTargetConfig(strategy='forecast'), + ) + enabled_logic = make_logic( + logic_cls, + min_grid_charge_soc=0.55, + preserve_min_grid_charge_soc=True, + grid_charge_target=GridChargeTargetConfig(strategy='forecast'), + ) + + assert disabled_logic.calculate(calc_input, datetime.datetime( + 2026, 4, 30, 5, 0, 0, tzinfo=datetime.timezone.utc)) + assert enabled_logic.calculate(calc_input, datetime.datetime( + 2026, 4, 30, 5, 0, 0, tzinfo=datetime.timezone.utc)) + + disabled_result = disabled_logic.get_inverter_control_settings() + disabled_output = disabled_logic.get_calculation_output() + enabled_result = enabled_logic.get_inverter_control_settings() + enabled_output = enabled_logic.get_calculation_output() + + high_price_required_energy = 3000.0 + floor_usable_energy = CAPACITY_WH * (0.55 - MIN_SOC) + expected_reserve = floor_usable_energy + high_price_required_energy + + assert disabled_result.allow_discharge is True + assert disabled_output.reserved_energy == pytest.approx( + high_price_required_energy) + assert enabled_result.allow_discharge is False + assert enabled_output.reserved_energy == pytest.approx(expected_reserve) diff --git a/tests/batcontrol/test_core.py b/tests/batcontrol/test_core.py index 911ae0c2..1278babb 100644 --- a/tests/batcontrol/test_core.py +++ b/tests/batcontrol/test_core.py @@ -394,6 +394,36 @@ 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_warns_when_min_grid_charge_soc_exceeds_grid_charge_limit( self, mock_config, mocker, caplog): core_module = "batcontrol.core" @@ -505,6 +535,207 @@ 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', + }) + 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' + 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', + }) + 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' + 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', + }) + 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 897282ef..3732a9c5 100644 --- a/tests/batcontrol/test_mqtt_api.py +++ b/tests/batcontrol/test_mqtt_api.py @@ -62,6 +62,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 @@ -181,6 +184,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.""" @@ -296,6 +309,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."""