Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions config/batcontrol_config_dummy.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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

#--------------------------
Expand Down
10 changes: 9 additions & 1 deletion docs/configuration/batcontrol-configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,14 +80,22 @@ 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.
`always_allow_discharge_limit` & `max_charging_from_grid_limit` is explained [here](../getting-started/how-batcontrol-works.md).

![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.

Expand Down
6 changes: 4 additions & 2 deletions docs/integrations/mqtt-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 11 additions & 1 deletion src/batcontrol/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()):
Expand Down
53 changes: 42 additions & 11 deletions src/batcontrol/logic/default.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
150 changes: 150 additions & 0 deletions src/batcontrol/logic/grid_charge_target.py
Original file line number Diff line number Diff line change
@@ -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)
12 changes: 11 additions & 1 deletion src/batcontrol/logic/logic_interface.py
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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.
Expand Down Expand Up @@ -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:
Expand All @@ -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:
Expand Down
Loading