Skip to content

Commit c258d34

Browse files
filiplajszczakMaStr
authored andcommitted
logic: add forecast grid charge target strategy
1 parent d33154d commit c258d34

14 files changed

Lines changed: 828 additions & 38 deletions

config/batcontrol_config_dummy.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ battery_control:
1919
always_allow_discharge_limit: 0.90 # 0.00 to 1.00 above this SOC limit using energy from the battery is always allowed
2020
max_charging_from_grid_limit: 0.89 # 0.00 to 1.00 charging from the grid is only allowed until this SOC limit
2121
# min_grid_charge_soc: 0.55 # optional 0.00 to 1.00 target to preserve/charge before expensive slots
22+
# grid_charge_target_strategy: fixed # fixed = use min_grid_charge_soc unchanged; forecast = keep it as reserve after expensive slots
2223
min_recharge_amount: 100 # in Wh, start & minimum amount of energy to recharge the battery
2324

2425
#--------------------------

docs/configuration/batcontrol-configuration.md

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,14 +80,22 @@ battery_control:
8080
always_allow_discharge_limit: 0.90
8181
max_charging_from_grid_limit: 0.89
8282
min_grid_charge_soc: 0.55 # optional: grid-charge to this SoC before expensive slots
83+
grid_charge_target_strategy: fixed # optional: fixed or forecast
8384
min_recharge_amount: 100
8485
```
8586
Details about the Price configuration can be found on [price difference calculation](../features/price-difference-calculation.md) page.
8687
`always_allow_discharge_limit` & `max_charging_from_grid_limit` is explained [here](../getting-started/how-batcontrol-works.md).
8788

8889
![Picture of different parameters on battery soc](../assets/battery_limits_parameter.png)
8990

90-
`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`.
91+
`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.
92+
93+
`grid_charge_target_strategy` controls how `min_grid_charge_soc` is applied:
94+
95+
- `fixed` (default): grid-charge toward `min_grid_charge_soc` when charging is economical.
96+
- `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`.
97+
98+
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.
9199

92100
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.
93101

docs/integrations/mqtt-api.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -109,8 +109,10 @@ See [Forecast Metrics](forecast-metrics.md) for a detailed explanation of these
109109
- `house/batcontrol/always_allow_discharge_limit_capacity` - Always discharge limit in Wh
110110
- `house/batcontrol/max_charging_from_grid_limit` - Max charging from grid limit (0.0-1.0)
111111
- `house/batcontrol/max_charging_from_grid_limit_percent` - Max charging from grid limit in %
112-
- `house/batcontrol/min_grid_charge_soc` - Optional minimum grid-charge target (0.0-1.0)
113-
- `house/batcontrol/min_grid_charge_soc_percent` - Optional minimum grid-charge target in %
112+
- `house/batcontrol/min_grid_charge_soc` - Optional configured minimum grid-charge target (0.0-1.0)
113+
- `house/batcontrol/min_grid_charge_soc_percent` - Optional configured minimum grid-charge target in %
114+
- `house/batcontrol/effective_min_grid_charge_soc` - Runtime effective grid-charge target after strategy calculation (0.0-1.0)
115+
- `house/batcontrol/effective_min_grid_charge_soc_percent` - Runtime effective grid-charge target after strategy calculation in %
114116
- `house/batcontrol/production_offset` - Production offset multiplier (`1.0` = 100%, `0.8` = 80%, etc.)
115117

116118
### Peak Shaving

src/batcontrol/core.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
from .logic import CalculationInput, CalculationParameters
3232
from .logic import CommonLogic
3333
from .logic import PeakShavingConfig
34+
from .logic.grid_charge_target import GridChargeTargetConfig
3435

3536
from .dynamictariff import DynamicTariff as tariff_factory
3637
from .inverter import Inverter as inverter_factory
@@ -261,6 +262,10 @@ def __init__(self, configdict: dict):
261262
self.batconfig.get('min_grid_charge_soc', None),
262263
'battery_control.min_grid_charge_soc'
263264
)
265+
self.grid_charge_target_config = (
266+
GridChargeTargetConfig.from_battery_control_config(self.batconfig)
267+
)
268+
self.grid_charge_target_strategy = self.grid_charge_target_config.strategy
264269
self.preserve_min_grid_charge_soc = False
265270
if (self.min_grid_charge_soc is not None
266271
and self.min_grid_charge_soc > self.max_charging_from_grid_limit):
@@ -698,14 +703,16 @@ def _run_once(self):
698703
self.peak_shaving_config,
699704
enabled=peak_shaving_config_enabled and not evcc_disable_peak_shaving,
700705
)
706+
max_capacity = self.get_max_capacity()
701707
calc_parameters = CalculationParameters(
702708
self.max_charging_from_grid_limit,
703709
self.min_price_difference,
704710
self.min_price_difference_rel,
705-
self.get_max_capacity(),
711+
max_capacity,
706712
min_grid_charge_soc=self.min_grid_charge_soc,
707713
preserve_min_grid_charge_soc=self.preserve_min_grid_charge_soc,
708714
peak_shaving=ps_runtime,
715+
grid_charge_target=self.grid_charge_target_config,
709716
)
710717

711718
self.last_logic_instance = this_logic_run
@@ -740,6 +747,9 @@ def _run_once(self):
740747
calc_input.stored_usable_energy, calc_input.free_capacity
741748
)
742749
self.mqtt_api.publish_forecast_min_battery(forecast_min_wh)
750+
if calc_output.effective_min_grid_charge_soc is not None:
751+
self.mqtt_api.publish_effective_min_grid_charge_soc(
752+
calc_output.effective_min_grid_charge_soc)
743753

744754
if self.discharge_blocked and not \
745755
self.general_logic.is_discharge_always_allowed_soc(self.get_SOC()):

src/batcontrol/logic/default.py

Lines changed: 42 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@
88
from .logic_interface import CalculationOutput, InverterControlSettings
99
from .common import CommonLogic
1010
from .decision_logging import GridRechargeDecision, log_grid_recharge_decision
11+
from .grid_charge_target import (
12+
apply_grid_charge_target_to_recharge,
13+
apply_grid_charge_target_to_reserve,
14+
)
1115

1216
# Minimum remaining time in hours to prevent division by very small numbers
1317
# 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
6367
self.calculation_output = CalculationOutput(
6468
reserved_energy=0.0,
6569
required_recharge_energy=0.0,
66-
min_dynamic_price_difference=0.0
70+
min_dynamic_price_difference=0.0,
71+
effective_min_grid_charge_soc=(
72+
self.calculation_parameters.min_grid_charge_soc
73+
)
6774
)
6875

6976
self.inverter_control_settings = self.calculate_inverter_mode(
@@ -315,12 +322,24 @@ def __is_discharge_allowed(self, calc_input: CalculationInput,
315322
min_dynamic_price_difference
316323
)
317324
)
318-
reserved_storage = self.common.apply_min_grid_charge_soc_reserve(
319-
reserved_storage,
320-
calc_input.stored_energy,
321-
calc_input.stored_usable_energy,
322-
self.calculation_parameters.min_grid_charge_soc,
323-
min_grid_charge_soc_active
325+
min_soc_energy = max(
326+
0.0,
327+
calc_input.stored_energy - calc_input.stored_usable_energy,
328+
)
329+
reserve_target = apply_grid_charge_target_to_reserve(
330+
config=self.calculation_parameters.grid_charge_target,
331+
reserved_energy=reserved_storage,
332+
min_soc_energy=min_soc_energy,
333+
configured_min_grid_charge_soc=(
334+
self.calculation_parameters.min_grid_charge_soc),
335+
max_capacity=self.calculation_parameters.max_capacity,
336+
max_charging_from_grid_limit=(
337+
self.calculation_parameters.max_charging_from_grid_limit),
338+
active=min_grid_charge_soc_active,
339+
)
340+
reserved_storage = reserve_target.energy
341+
self.calculation_output.effective_min_grid_charge_soc = (
342+
reserve_target.effective_soc
324343
)
325344

326345
self.calculation_output.reserved_energy = reserved_storage
@@ -458,13 +477,25 @@ def __get_required_recharge_energy(self, calc_input: CalculationInput,
458477
"[Rule] No additional energy required, because stored energy is sufficient."
459478
)
460479
recharge_energy = 0.0
480+
481+
if required_energy == 0.0:
461482
self.calculation_output.required_recharge_energy = recharge_energy
462483
return recharge_energy
463484

464-
recharge_energy = self.common.apply_min_grid_charge_soc_target(
465-
recharge_energy,
466-
calc_input.stored_energy,
467-
self.calculation_parameters.min_grid_charge_soc
485+
recharge_target = apply_grid_charge_target_to_recharge(
486+
config=self.calculation_parameters.grid_charge_target,
487+
recharge_energy=recharge_energy,
488+
required_energy=required_energy,
489+
stored_energy=calc_input.stored_energy,
490+
configured_min_grid_charge_soc=(
491+
self.calculation_parameters.min_grid_charge_soc),
492+
max_capacity=self.calculation_parameters.max_capacity,
493+
max_charging_from_grid_limit=(
494+
self.calculation_parameters.max_charging_from_grid_limit),
495+
)
496+
recharge_energy = recharge_target.energy
497+
self.calculation_output.effective_min_grid_charge_soc = (
498+
recharge_target.effective_soc
468499
)
469500

470501
free_capacity = calc_input.free_capacity
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
"""Grid-charge target strategy helpers."""
2+
3+
from dataclasses import dataclass
4+
from typing import Optional
5+
6+
GRID_CHARGE_TARGET_STRATEGY_FIXED = 'fixed'
7+
GRID_CHARGE_TARGET_STRATEGY_FORECAST = 'forecast'
8+
GRID_CHARGE_TARGET_STRATEGIES = (
9+
GRID_CHARGE_TARGET_STRATEGY_FIXED,
10+
GRID_CHARGE_TARGET_STRATEGY_FORECAST,
11+
)
12+
13+
14+
@dataclass(frozen=True)
15+
class GridChargeTargetConfig:
16+
"""Configuration for grid-charge target calculation."""
17+
18+
strategy: str = GRID_CHARGE_TARGET_STRATEGY_FIXED
19+
20+
@classmethod
21+
def from_battery_control_config(cls, config: dict) -> 'GridChargeTargetConfig':
22+
"""Create target strategy configuration from battery_control config."""
23+
return cls(
24+
strategy=_parse_grid_charge_target_strategy(
25+
config.get(
26+
'grid_charge_target_strategy',
27+
GRID_CHARGE_TARGET_STRATEGY_FIXED,
28+
)
29+
),
30+
)
31+
32+
33+
@dataclass(frozen=True)
34+
class GridChargeTargetResult:
35+
"""Energy adjusted by a grid-charge target and the effective target SoC."""
36+
37+
energy: float
38+
effective_soc: Optional[float]
39+
40+
41+
def _parse_grid_charge_target_strategy(value) -> str:
42+
"""Parse the grid-charge target strategy config value."""
43+
strategy = str(value).strip().lower()
44+
if strategy not in GRID_CHARGE_TARGET_STRATEGIES:
45+
raise ValueError(
46+
f"battery_control.grid_charge_target_strategy must be one of "
47+
f"{GRID_CHARGE_TARGET_STRATEGIES}, got {value!r}"
48+
)
49+
return strategy
50+
51+
52+
def _validate_strategy(strategy: str) -> None:
53+
if strategy not in GRID_CHARGE_TARGET_STRATEGIES:
54+
raise ValueError(
55+
f"grid_charge_target_strategy must be one of "
56+
f"{GRID_CHARGE_TARGET_STRATEGIES}, got '{strategy}'"
57+
)
58+
59+
60+
def _capped_forecast_soc(
61+
configured_min_grid_charge_soc: float,
62+
additional_reserve_energy: float,
63+
max_capacity: float,
64+
max_charging_from_grid_limit: float) -> float:
65+
"""Return floor-plus-reserve target SoC capped by the grid-charge limit."""
66+
if max_capacity <= 0:
67+
raise ValueError("max_capacity must be greater than 0")
68+
target_soc = configured_min_grid_charge_soc + (
69+
additional_reserve_energy / max_capacity)
70+
return min(target_soc, max_charging_from_grid_limit)
71+
72+
73+
def apply_grid_charge_target_to_recharge(
74+
config: GridChargeTargetConfig,
75+
recharge_energy: float,
76+
required_energy: float,
77+
stored_energy: float,
78+
configured_min_grid_charge_soc: Optional[float],
79+
max_capacity: float,
80+
max_charging_from_grid_limit: float) -> GridChargeTargetResult:
81+
"""Apply the configured grid-charge target to recharge energy.
82+
83+
``fixed`` applies the configured SoC floor: when grid charging is already
84+
required, charge at least up to ``min_grid_charge_soc``.
85+
86+
``forecast`` uses the existing high-price-slot ``required_energy`` as the
87+
source of truth and treats ``min_grid_charge_soc`` as the reserve that
88+
should remain after those slots. The target is therefore the configured
89+
floor plus the required high-price energy.
90+
"""
91+
if configured_min_grid_charge_soc is None:
92+
return GridChargeTargetResult(recharge_energy, None)
93+
94+
_validate_strategy(config.strategy)
95+
effective_soc = configured_min_grid_charge_soc
96+
if required_energy <= 0.0:
97+
return GridChargeTargetResult(recharge_energy, effective_soc)
98+
99+
if config.strategy == GRID_CHARGE_TARGET_STRATEGY_FORECAST:
100+
effective_soc = _capped_forecast_soc(
101+
configured_min_grid_charge_soc,
102+
required_energy,
103+
max_capacity,
104+
max_charging_from_grid_limit,
105+
)
106+
107+
target_energy = max_capacity * effective_soc
108+
soc_recharge_energy = max(0.0, target_energy - stored_energy)
109+
adjusted_recharge_energy = max(recharge_energy, soc_recharge_energy)
110+
111+
if config.strategy == GRID_CHARGE_TARGET_STRATEGY_FORECAST:
112+
max_grid_charge_energy = max(
113+
0.0,
114+
max_capacity * max_charging_from_grid_limit - stored_energy,
115+
)
116+
adjusted_recharge_energy = min(
117+
adjusted_recharge_energy,
118+
max_grid_charge_energy,
119+
)
120+
121+
return GridChargeTargetResult(adjusted_recharge_energy, effective_soc)
122+
123+
124+
def apply_grid_charge_target_to_reserve(
125+
config: GridChargeTargetConfig,
126+
reserved_energy: float,
127+
min_soc_energy: float,
128+
configured_min_grid_charge_soc: Optional[float],
129+
max_capacity: float,
130+
max_charging_from_grid_limit: float,
131+
active: bool) -> GridChargeTargetResult:
132+
"""Apply the configured grid-charge target to protected reserve energy."""
133+
if configured_min_grid_charge_soc is None or not active:
134+
return GridChargeTargetResult(reserved_energy, configured_min_grid_charge_soc)
135+
136+
_validate_strategy(config.strategy)
137+
effective_soc = configured_min_grid_charge_soc
138+
139+
if config.strategy == GRID_CHARGE_TARGET_STRATEGY_FORECAST:
140+
effective_soc = _capped_forecast_soc(
141+
configured_min_grid_charge_soc,
142+
reserved_energy,
143+
max_capacity,
144+
max_charging_from_grid_limit,
145+
)
146+
147+
target_energy = max_capacity * effective_soc
148+
target_usable_energy = max(0.0, target_energy - min_soc_energy)
149+
adjusted_reserved_energy = max(reserved_energy, target_usable_energy)
150+
return GridChargeTargetResult(adjusted_reserved_energy, effective_soc)

src/batcontrol/logic/logic_interface.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import logging
22
from abc import ABC, abstractmethod
33
from dataclasses import dataclass, field
4-
from typing import Optional
4+
from typing import Optional, Any
55
import datetime
66
import numpy as np
77

@@ -11,6 +11,12 @@
1111
PEAK_SHAVING_VALID_MODES = ('time', 'price', 'combined')
1212

1313

14+
def _default_grid_charge_target_config():
15+
"""Create default grid-charge target strategy config lazily."""
16+
from .grid_charge_target import GridChargeTargetConfig # pylint: disable=import-outside-toplevel
17+
return GridChargeTargetConfig()
18+
19+
1420
@dataclass
1521
class PeakShavingConfig:
1622
""" Holds peak shaving configuration parameters, initialized from the config dict.
@@ -117,6 +123,9 @@ class CalculationParameters:
117123
# Peak shaving sub-configuration. evcc may set ``enabled=False`` for a
118124
# single calculation cycle via ``dataclasses.replace`` in core.py.
119125
peak_shaving: PeakShavingConfig = field(default_factory=PeakShavingConfig)
126+
# Grid-charge target strategy sub-configuration. The concrete config class
127+
# lives in grid_charge_target.py; use a lazy factory to avoid import cycles.
128+
grid_charge_target: Any = field(default_factory=_default_grid_charge_target_config)
120129

121130
def __post_init__(self):
122131
if self.min_grid_charge_soc is not None:
@@ -138,6 +147,7 @@ class CalculationOutput:
138147
reserved_energy: float = 0.0
139148
required_recharge_energy: float = 0.0
140149
min_dynamic_price_difference: float = 0.05
150+
effective_min_grid_charge_soc: Optional[float] = None
141151

142152
@dataclass
143153
class InverterControlSettings:

0 commit comments

Comments
 (0)