Skip to content

Commit e054693

Browse files
logic: add forecast grid charge target strategy
1 parent 9dcd970 commit e054693

7 files changed

Lines changed: 607 additions & 4 deletions

File tree

config/batcontrol_config_dummy.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ 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 = raise it from forecasted expensive-slot need
23+
# grid_charge_forecast_pv_factor: 1.0 # forecast strategy only: 0.0 to 1.0 multiplier for PV forecast trust
2224
min_recharge_amount: 100 # in Wh, start & minimum amount of energy to recharge the battery
2325

2426
#--------------------------

src/batcontrol/core.py

Lines changed: 79 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,11 @@
3030
from .logic import CalculationInput, CalculationParameters
3131
from .logic import CommonLogic
3232
from .logic import PeakShavingConfig
33+
from .logic.grid_charge_target import (
34+
GRID_CHARGE_TARGET_STRATEGIES,
35+
GRID_CHARGE_TARGET_STRATEGY_FIXED,
36+
calculate_effective_grid_charge_soc,
37+
)
3338

3439
from .dynamictariff import DynamicTariff as tariff_factory
3540
from .inverter import Inverter as inverter_factory
@@ -80,6 +85,27 @@ def _parse_optional_ratio(value, config_key: str) -> Optional[float]:
8085
return ratio
8186

8287

88+
def _parse_ratio(value, config_key: str) -> float:
89+
"""Parse a required 0..1 ratio config value."""
90+
ratio = _parse_optional_ratio(value, config_key)
91+
if ratio is None:
92+
raise ValueError(
93+
f"{config_key} must be numeric between 0 and 1, got None"
94+
)
95+
return ratio
96+
97+
98+
def _parse_grid_charge_target_strategy(value) -> str:
99+
"""Parse the grid-charge target strategy config value."""
100+
strategy = str(value).strip().lower()
101+
if strategy not in GRID_CHARGE_TARGET_STRATEGIES:
102+
raise ValueError(
103+
f"battery_control.grid_charge_target_strategy must be one of "
104+
f"{GRID_CHARGE_TARGET_STRATEGIES}, got {value!r}"
105+
)
106+
return strategy
107+
108+
83109
class Batcontrol:
84110
""" Main class for Batcontrol, handles the logic and control of the battery system """
85111
general_logic = None # type: CommonLogic
@@ -235,6 +261,16 @@ def __init__(self, configdict: dict):
235261
self.batconfig.get('min_grid_charge_soc', None),
236262
'battery_control.min_grid_charge_soc'
237263
)
264+
self.grid_charge_target_strategy = _parse_grid_charge_target_strategy(
265+
self.batconfig.get(
266+
'grid_charge_target_strategy',
267+
GRID_CHARGE_TARGET_STRATEGY_FIXED,
268+
)
269+
)
270+
self.grid_charge_forecast_pv_factor = _parse_ratio(
271+
self.batconfig.get('grid_charge_forecast_pv_factor', 1.0),
272+
'battery_control.grid_charge_forecast_pv_factor'
273+
)
238274
self.preserve_min_grid_charge_soc = False
239275
if (self.min_grid_charge_soc is not None
240276
and self.min_grid_charge_soc > self.max_charging_from_grid_limit):
@@ -616,12 +652,19 @@ def run(self):
616652
self.peak_shaving_config,
617653
enabled=peak_shaving_config_enabled and not evcc_disable_peak_shaving,
618654
)
655+
effective_min_grid_charge_soc = self.__calculate_effective_min_grid_charge_soc(
656+
calc_input,
657+
production,
658+
consumption,
659+
prices,
660+
)
661+
619662
calc_parameters = CalculationParameters(
620663
self.max_charging_from_grid_limit,
621664
self.min_price_difference,
622665
self.min_price_difference_rel,
623666
self.get_max_capacity(),
624-
min_grid_charge_soc=self.min_grid_charge_soc,
667+
min_grid_charge_soc=effective_min_grid_charge_soc,
625668
preserve_min_grid_charge_soc=self.preserve_min_grid_charge_soc,
626669
peak_shaving=ps_runtime,
627670
)
@@ -666,6 +709,41 @@ def run(self):
666709
else:
667710
self.avoid_discharging()
668711

712+
def __calculate_effective_min_grid_charge_soc(
713+
self,
714+
calc_input: CalculationInput,
715+
production,
716+
consumption,
717+
prices) -> Optional[float]:
718+
effective_min_grid_charge_soc = calculate_effective_grid_charge_soc(
719+
strategy=self.grid_charge_target_strategy,
720+
configured_min_grid_charge_soc=self.min_grid_charge_soc,
721+
max_charging_from_grid_limit=self.max_charging_from_grid_limit,
722+
max_capacity=self.get_max_capacity(),
723+
min_soc_energy=max(
724+
0.0,
725+
calc_input.stored_energy - calc_input.stored_usable_energy,
726+
),
727+
production=production,
728+
consumption=consumption,
729+
prices=prices,
730+
min_price_difference=self.min_price_difference,
731+
min_price_difference_rel=self.min_price_difference_rel,
732+
pv_forecast_factor=self.grid_charge_forecast_pv_factor,
733+
)
734+
if effective_min_grid_charge_soc != self.min_grid_charge_soc:
735+
logger.info(
736+
'Forecast grid-charge target raised min_grid_charge_soc '
737+
'from %.1f%% to %.1f%%',
738+
self.min_grid_charge_soc * 100,
739+
effective_min_grid_charge_soc * 100,
740+
)
741+
if (self.mqtt_api is not None
742+
and effective_min_grid_charge_soc is not None):
743+
self.mqtt_api.publish_effective_min_grid_charge_soc(
744+
effective_min_grid_charge_soc)
745+
return effective_min_grid_charge_soc
746+
669747
def __set_charge_rate(self, charge_rate: int):
670748
""" Set charge rate and publish to mqtt """
671749
self.last_charge_rate = charge_rate
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
"""Effective grid-charge SoC target calculation."""
2+
3+
from typing import Optional, Sequence
4+
5+
GRID_CHARGE_TARGET_STRATEGY_FIXED = 'fixed'
6+
GRID_CHARGE_TARGET_STRATEGY_FORECAST = 'forecast'
7+
GRID_CHARGE_TARGET_STRATEGIES = (
8+
GRID_CHARGE_TARGET_STRATEGY_FIXED,
9+
GRID_CHARGE_TARGET_STRATEGY_FORECAST,
10+
)
11+
12+
13+
def _ordered_values(values) -> Sequence[float]:
14+
if isinstance(values, dict):
15+
if not values:
16+
return []
17+
keys = set(values.keys())
18+
error_message = (
19+
"forecast dict values must use consecutive integer "
20+
"indices starting at 0"
21+
)
22+
if not all(isinstance(index, int) for index in keys):
23+
raise ValueError(error_message)
24+
expected_keys = set(range(max(keys) + 1))
25+
if keys != expected_keys:
26+
raise ValueError(error_message)
27+
return [values[index] for index in range(max(keys) + 1)]
28+
return list(values)
29+
30+
31+
def _calculate_min_dynamic_price_difference(
32+
current_price: float,
33+
min_price_difference: float,
34+
min_price_difference_rel: float) -> float:
35+
return max(min_price_difference,
36+
min_price_difference_rel * abs(current_price))
37+
38+
39+
def calculate_effective_grid_charge_soc(
40+
strategy: str,
41+
configured_min_grid_charge_soc: Optional[float],
42+
max_charging_from_grid_limit: float,
43+
max_capacity: float,
44+
min_soc_energy: float,
45+
production,
46+
consumption,
47+
prices,
48+
min_price_difference: float,
49+
min_price_difference_rel: float = 0.0,
50+
pv_forecast_factor: float = 1.0) -> Optional[float]:
51+
"""Calculate the effective minimum grid-charge SoC for this evaluation.
52+
53+
``fixed`` returns the configured target unchanged. ``forecast`` treats the
54+
configured target as a floor and raises it when future expensive-slot net
55+
demand implies a higher target. Forecast PV can be discounted with
56+
``pv_forecast_factor`` to account for uncertain PV ramps.
57+
"""
58+
if configured_min_grid_charge_soc is None:
59+
return None
60+
if strategy not in GRID_CHARGE_TARGET_STRATEGIES:
61+
raise ValueError(
62+
f"grid_charge_target_strategy must be one of "
63+
f"{GRID_CHARGE_TARGET_STRATEGIES}, got '{strategy}'"
64+
)
65+
if strategy == GRID_CHARGE_TARGET_STRATEGY_FIXED:
66+
return configured_min_grid_charge_soc
67+
if max_capacity <= 0:
68+
raise ValueError("max_capacity must be greater than 0")
69+
if not 0 <= pv_forecast_factor <= 1:
70+
raise ValueError(
71+
"grid_charge_forecast_pv_factor must be between 0 and 1, "
72+
f"got {pv_forecast_factor}"
73+
)
74+
75+
production_values = _ordered_values(production)
76+
consumption_values = _ordered_values(consumption)
77+
price_values = _ordered_values(prices)
78+
max_slot = min(len(production_values), len(consumption_values), len(price_values))
79+
if max_slot < 2:
80+
return configured_min_grid_charge_soc
81+
82+
current_price = price_values[0]
83+
min_dynamic_price_difference = _calculate_min_dynamic_price_difference(
84+
current_price,
85+
min_price_difference,
86+
min_price_difference_rel,
87+
)
88+
89+
# Evaluate until the next price slot that is no more expensive than the
90+
# current slot. This keeps the target tied to the current cheap/economical
91+
# charging window rather than charging for the whole forecast horizon.
92+
for slot in range(1, max_slot):
93+
if price_values[slot] <= current_price:
94+
max_slot = slot
95+
break
96+
97+
forecast_need = 0.0
98+
for slot in range(1, max_slot):
99+
if price_values[slot] <= current_price + min_dynamic_price_difference:
100+
continue
101+
discounted_pv = production_values[slot] * pv_forecast_factor
102+
forecast_need += max(0.0, consumption_values[slot] - discounted_pv)
103+
104+
forecast_target = (min_soc_energy + forecast_need) / max_capacity
105+
forecast_target = min(forecast_target, max_charging_from_grid_limit)
106+
return max(configured_min_grid_charge_soc, forecast_target)

src/batcontrol/mqtt_api.py

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,10 @@
99
- /mode: operational mode (-1 = charge from grid, 0 = avoid discharge, 8 = limit battery charge, 10 = discharge allowed)
1010
- /max_charging_from_grid_limit: charge limit in 0.1-1
1111
- /max_charging_from_grid_limit_percent: charge limit in %
12-
- /min_grid_charge_soc: optional minimum grid-charge target in 0.0-1.0
13-
- /min_grid_charge_soc_percent: optional minimum grid-charge target in %
12+
- /min_grid_charge_soc: configured optional minimum grid-charge target in 0.0-1.0
13+
- /min_grid_charge_soc_percent: configured optional minimum grid-charge target in %
14+
- /effective_min_grid_charge_soc: runtime effective minimum grid-charge target in 0.0-1.0
15+
- /effective_min_grid_charge_soc_percent: runtime effective minimum grid-charge target in %
1416
- /always_allow_discharge_limit: always discharge limit in 0.1-1
1517
- /always_allow_discharge_limit_percent: always discharge limit in %
1618
- /always_allow_discharge_limit_capacity: always discharge limit in Wh
@@ -390,7 +392,7 @@ def publish_max_charging_from_grid_limit(
390392
)
391393

392394
def publish_min_grid_charge_soc(self, min_grid_charge_soc: float) -> None:
393-
""" Publish the optional minimum grid-charge SoC target to MQTT
395+
""" Publish the configured optional minimum grid-charge SoC target to MQTT
394396
/min_grid_charge_soc_percent
395397
/min_grid_charge_soc as digit.
396398
"""
@@ -404,6 +406,22 @@ def publish_min_grid_charge_soc(self, min_grid_charge_soc: float) -> None:
404406
f'{min_grid_charge_soc:.2f}'
405407
)
406408

409+
def publish_effective_min_grid_charge_soc(
410+
self, effective_min_grid_charge_soc: float) -> None:
411+
""" Publish the runtime effective minimum grid-charge SoC target to MQTT
412+
/effective_min_grid_charge_soc_percent
413+
/effective_min_grid_charge_soc as digit.
414+
"""
415+
if self.client.is_connected():
416+
self.client.publish(
417+
self.base_topic + '/effective_min_grid_charge_soc_percent',
418+
f'{effective_min_grid_charge_soc * 100:.0f}'
419+
)
420+
self.client.publish(
421+
self.base_topic + '/effective_min_grid_charge_soc',
422+
f'{effective_min_grid_charge_soc:.2f}'
423+
)
424+
407425
def publish_min_price_difference(
408426
self, min_price_difference: float) -> None:
409427
""" Publish the minimum price difference to MQTT found in config
@@ -652,6 +670,16 @@ def send_mqtt_discovery_messages(self) -> None:
652670
"/min_grid_charge_soc_percent",
653671
entity_category="diagnostic")
654672

673+
self.publish_mqtt_discovery_message(
674+
"Effective Minimum Grid Charge SOC",
675+
"batcontrol_effective_min_grid_charge_soc",
676+
"sensor",
677+
"battery",
678+
"%",
679+
self.base_topic +
680+
"/effective_min_grid_charge_soc_percent",
681+
entity_category="diagnostic")
682+
655683
self.publish_mqtt_discovery_message(
656684
"Min Price Difference",
657685
"batcontrol_min_price_difference",

0 commit comments

Comments
 (0)