Skip to content

Commit 9d49273

Browse files
MaStrCopilotCopilot
authored
BC: Limit battery charge rate (#260)
* feat: Implement limit battery charge rate functionality - Added `min_pv_charge_rate` and `max_pv_charge_rate` to inverter configuration. - Introduced `MODE_LIMIT_BATTERY_CHARGE_RATE` to allow limiting PV charging while permitting battery discharge. - Updated `Batcontrol` class to handle new charge rate limits and modes. - Implemented `limit_battery_charge_rate` method in `Batcontrol` to apply dynamic limits based on configuration. - Enhanced `Dummy` and `FroniusWR` inverter classes to support the new limit battery charge mode. - Updated MQTT publishing to include commands for setting battery charge limits. - Added unit tests for the new functionality, including edge cases for charge limits. * Enhance reslient wrapper * fix: Address PR #260 review feedback - Add assertions to test_production_offset_applied_to_forecast to verify that last_production[1] and [2] reflect the 50% offset, and remove misplaced test logic that was left over from deleted test methods - Add warning log in api_set_mode when MODE_LIMIT_BATTERY_CHARGE_RATE is used but _limit_battery_charge_rate is still -1 (no limit set), so callers are informed about the silent fallback to allow-discharging - Remove extra blank line at end of tests/batcontrol/inverter/test_fronius_ids.py - Remove unused Mock import from tests/batcontrol/test_core.py Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: Correct swapped docstrings in InverterInterface set_mode_avoid_discharge was documented as 'allow discharge mode' and set_mode_allow_discharge was documented as 'avoid discharge mode'. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs: Clarify min_pv_charge_rate comment in config The previous comment implied that setting min_pv_charge_rate to 0 was needed to allow complete charge blocking. In reality, the implementation skips the minimum floor when limit_charge_rate == 0, so a requested limit of 0 always results in complete charge blocking regardless of min_pv_charge_rate. The comment now reflects this actual behavior. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix review feedback: clean up imports, restore tests, fix charge rate bounds logic, remove MQTT retain (#292) * Initial plan * fix: Address PR review feedback - clean up imports, restore tests, fix bounds logic, remove retain Co-authored-by: MaStr <1036501+MaStr@users.noreply.github.com> * refactor: Move min/max PV charge rate guard to initialization Co-authored-by: MaStr <1036501+MaStr@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: MaStr <1036501+MaStr@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com> Co-authored-by: MaStr <1036501+MaStr@users.noreply.github.com>
1 parent c306b04 commit 9d49273

14 files changed

Lines changed: 737 additions & 77 deletions

config/batcontrol_config_dummy.yaml

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,13 @@ inverter:
5252
# user: customer #customer or technician lowercase only!!
5353
# password: YOUR-PASSWORD #
5454
max_grid_charge_rate: 5000 # Watt, Upper limit for Grid to Battery charge rate.
55-
max_pv_charge_rate: 0 # Watt, This allows to limit the PV to Battery charge rate. Set to 0 for unlimited charging.
55+
max_pv_charge_rate: 0 # Watt, STATIC upper limit for PV to Battery charge rate. Set to 0 for unlimited charging.
56+
# Applied in MODE_ALLOW_DISCHARGING (10) and as upper bound in MODE_LIMIT_BATTERY_CHARGE_RATE (8).
57+
min_pv_charge_rate: 0 # Watt, STATIC lower limit for MODE_LIMIT_BATTERY_CHARGE_RATE (8).
58+
# Only applied when the requested limit is > 0 (i.e. partial charging).
59+
# A requested limit of 0 (complete charge blocking) bypasses this floor.
60+
# Example: set to 100 to ensure at least 100W charging when a non-zero limit is active.
61+
# Only affects mode 8; ignored in other modes.
5662
# fronius_inverter_id: '1' # Optional: ID of the inverter in Fronius API (default: '1')
5763
# fronius_controller_id: '0' # Optional: ID of the controller in Fronius API (default: '0')
5864
enable_resilient_wrapper: false # Enable resilient wrapper for graceful outage handling (default: false)

src/batcontrol/core.py

Lines changed: 97 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,17 +10,14 @@
1010
1111
"""
1212
# %%
13-
from dataclasses import dataclass
1413
import datetime
1514
import time
1615
import os
1716
import logging
1817
import platform
19-
from typing import Callable
2018

2119
import pytz
2220
import numpy as np
23-
import platform
2421

2522
from .mqtt_api import MqttApi
2623
from .evcc_api import EvccApi
@@ -46,6 +43,7 @@
4643
FORECAST_TOLERANCE = 3 # Acceptable tolerance for forecast hours
4744

4845
MODE_ALLOW_DISCHARGING = 10
46+
MODE_LIMIT_BATTERY_CHARGE_RATE = 8 # Limit PV charge, allow discharge
4947
MODE_AVOID_DISCHARGING = 0
5048
MODE_FORCE_CHARGING = -1
5149

@@ -59,9 +57,10 @@ class Batcontrol:
5957
def __init__(self, configdict: dict):
6058
# For API
6159
self.api_overwrite = False
62-
# -1 = charge from grid , 0 = avoid discharge , 10 = discharge allowed
60+
# -1 = charge from grid , 0 = avoid discharge , 8 = limit battery charge, 10 = discharge allowed
6361
self.last_mode = None
6462
self.last_charge_rate = 0
63+
self._limit_battery_charge_rate = -1 # Dynamic battery charge rate limit (-1 = no limit)
6564
self.last_prices = None
6665
self.last_consumption = None
6766
self.last_production = None
@@ -150,6 +149,28 @@ def __init__(self, configdict: dict):
150149
self.inverter = inverter_factory.create_inverter(
151150
config['inverter'])
152151

152+
# Get PV charge rate limits from inverter config (with defaults),
153+
# falling back to inverter attribute for backward compatibility
154+
self.max_pv_charge_rate = config['inverter'].get(
155+
'max_pv_charge_rate',
156+
getattr(self.inverter, 'max_pv_charge_rate', 0),
157+
)
158+
self.min_pv_charge_rate = config['inverter'].get('min_pv_charge_rate', 0)
159+
160+
# Validate min/max PV charge rate configuration at startup
161+
if (
162+
self.max_pv_charge_rate > 0
163+
and self.min_pv_charge_rate > 0
164+
and self.min_pv_charge_rate > self.max_pv_charge_rate
165+
):
166+
logger.warning(
167+
'Configured min_pv_charge_rate (%d W) is greater than '
168+
'max_pv_charge_rate (%d W). Adjusting minimum to max.',
169+
self.min_pv_charge_rate,
170+
self.max_pv_charge_rate,
171+
)
172+
self.min_pv_charge_rate = self.max_pv_charge_rate
173+
153174
self.pvsettings = config['pvinstallations']
154175
self.fc_solar = solar_factory.create_solar_provider(
155176
self.pvsettings,
@@ -219,6 +240,11 @@ def __init__(self, configdict: dict):
219240
self.api_set_charge_rate,
220241
int
221242
)
243+
self.mqtt_api.register_set_callback(
244+
'limit_battery_charge_rate',
245+
self.api_set_limit_battery_charge_rate,
246+
int
247+
)
222248
self.mqtt_api.register_set_callback(
223249
'always_allow_discharge_limit',
224250
self.api_set_always_allow_discharge_limit,
@@ -515,7 +541,10 @@ def run(self):
515541
inverter_settings.allow_discharge = False
516542

517543
if inverter_settings.allow_discharge:
518-
self.allow_discharging()
544+
if inverter_settings.limit_battery_charge_rate >= 0:
545+
self.limit_battery_charge_rate(inverter_settings.limit_battery_charge_rate)
546+
else:
547+
self.allow_discharging()
519548
elif inverter_settings.charge_from_grid:
520549
self.force_charge(inverter_settings.charge_rate)
521550
else:
@@ -557,6 +586,39 @@ def force_charge(self, charge_rate=500):
557586
self.__set_mode(MODE_FORCE_CHARGING)
558587
self.__set_charge_rate(charge_rate)
559588

589+
def limit_battery_charge_rate(self, limit_charge_rate: int = 0):
590+
""" Limit PV charging rate while allowing battery discharge
591+
592+
Args:
593+
limit_charge_rate: Maximum charge rate in W (0 = no charging, -1 = no limit)
594+
"""
595+
# If -1, use no limit (don't apply mode 8)
596+
if limit_charge_rate < 0:
597+
self.allow_discharging()
598+
return
599+
600+
# Always enforce a non-negative limit
601+
effective_limit = max(0, limit_charge_rate)
602+
603+
if self.max_pv_charge_rate > 0:
604+
# Cap to the configured maximum
605+
effective_limit = min(effective_limit, self.max_pv_charge_rate)
606+
# Enforce minimum (guaranteed <= max_pv_charge_rate from init validation)
607+
if self.min_pv_charge_rate > 0 and effective_limit > 0:
608+
effective_limit = max(effective_limit, self.min_pv_charge_rate)
609+
else:
610+
# No max configured (<= 0): only enforce minimum if both are positive
611+
if self.min_pv_charge_rate > 0 and effective_limit > 0:
612+
effective_limit = max(effective_limit, self.min_pv_charge_rate)
613+
614+
logger.info('Mode: Limit Battery Charge Rate to %d W, discharge allowed', effective_limit)
615+
self.inverter.set_mode_limit_battery_charge(effective_limit)
616+
self.__set_mode(MODE_LIMIT_BATTERY_CHARGE_RATE)
617+
618+
# Publish limit via MQTT
619+
if self.mqtt_api is not None:
620+
self.mqtt_api.publish_limit_battery_charge_rate(effective_limit)
621+
560622
def __save_run_data(
561623
self,
562624
production,
@@ -744,6 +806,7 @@ def api_set_mode(self, mode: int):
744806
if mode not in [
745807
MODE_FORCE_CHARGING,
746808
MODE_AVOID_DISCHARGING,
809+
MODE_LIMIT_BATTERY_CHARGE_RATE,
747810
MODE_ALLOW_DISCHARGING]:
748811
logger.warning('API: Invalid mode %s', mode)
749812
return
@@ -756,6 +819,14 @@ def api_set_mode(self, mode: int):
756819
self.force_charge()
757820
elif mode == MODE_AVOID_DISCHARGING:
758821
self.avoid_discharging()
822+
elif mode == MODE_LIMIT_BATTERY_CHARGE_RATE:
823+
if self._limit_battery_charge_rate < 0:
824+
logger.warning(
825+
'API: Mode %d (limit battery charge rate) set but no valid '
826+
'limit configured. Set a limit via api_set_limit_battery_charge_rate '
827+
'first. Falling back to allow-discharging mode.',
828+
mode)
829+
self.limit_battery_charge_rate(self._limit_battery_charge_rate)
759830
elif mode == MODE_ALLOW_DISCHARGING:
760831
self.allow_discharging()
761832

@@ -770,6 +841,27 @@ def api_set_charge_rate(self, charge_rate: int):
770841
if charge_rate != self.last_charge_rate:
771842
self.force_charge(charge_rate)
772843

844+
def api_set_limit_battery_charge_rate(self, limit: int):
845+
""" Set dynamic battery charge rate limit from external call
846+
847+
Args:
848+
limit: Maximum battery charge rate in W (0 = no charging, -1 = no limit)
849+
"""
850+
if limit < -1:
851+
logger.warning('API: Invalid limit_battery_charge_rate %d W', limit)
852+
return
853+
854+
logger.info('API: Setting limit_battery_charge_rate to %d W', limit)
855+
self._limit_battery_charge_rate = limit
856+
857+
# If currently in MODE_LIMIT_BATTERY_CHARGE_RATE, apply immediately
858+
if self.last_mode == MODE_LIMIT_BATTERY_CHARGE_RATE:
859+
self.limit_battery_charge_rate(limit)
860+
861+
def api_get_limit_battery_charge_rate(self) -> int:
862+
""" Get current dynamic battery charge rate limit """
863+
return self._limit_battery_charge_rate
864+
773865
def api_set_always_allow_discharge_limit(self, limit: float):
774866
""" Set always allow discharge limit for battery control via external API request.
775867
The change is temporary and will not be written to the config file.

src/batcontrol/inverter/dummy.py

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ def __init__(self, config):
2525

2626
def set_mode_force_charge(self, chargerate=500):
2727
self.mode = 'force_charge'
28-
logger.debug(f'Dummy inverter: Set to force charge mode (rate: {chargerate}W)')
28+
logger.debug('Dummy inverter: Set to force charge mode (rate: %dW)', chargerate)
2929

3030
def set_mode_allow_discharge(self):
3131
self.mode = 'allow_discharge'
@@ -35,6 +35,11 @@ def set_mode_avoid_discharge(self):
3535
self.mode = 'avoid_discharge'
3636
logger.debug('Dummy inverter: Set to avoid discharge mode')
3737

38+
def set_mode_limit_battery_charge(self, limit_charge_rate: int):
39+
""" Dummy implementation for limit battery charge mode """
40+
self.mode = 'limit_battery_charge'
41+
logger.info('DUMMY: Limit battery charge rate to %d W', limit_charge_rate)
42+
3843
def get_capacity(self):
3944
return self.installed_capacity
4045

@@ -44,12 +49,10 @@ def get_SOC(self):
4449
def activate_mqtt(self, api_mqtt_api):
4550
# Dummy inverter doesn't support MQTT for simplicity
4651
logger.debug('Dummy inverter: MQTT activation ignored (not supported)')
47-
pass
4852

4953
def refresh_api_values(self):
50-
# Call parent implementation for basic MQTT publishing if available
51-
super().refresh_api_values()
54+
# No-op for dummy inverter - no values to refresh
55+
logger.debug('Dummy inverter: refresh_api_values called (no action needed)')
5256

5357
def shutdown(self):
54-
logger.info('Dummy inverter: Shutdown called (no action needed)')
55-
pass
58+
logger.info('Dummy inverter: Shutdown called (no action needed)')

src/batcontrol/inverter/fronius.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -538,6 +538,32 @@ def set_mode_allow_discharge(self):
538538

539539
return response
540540

541+
def set_mode_limit_battery_charge(self, limit_charge_rate: int):
542+
""" Limit PV charging rate while allowing battery discharge
543+
544+
Args:
545+
limit_charge_rate: Maximum charge rate in W (0 = no charging)
546+
"""
547+
if limit_charge_rate < 0:
548+
raise ValueError(f"limit_charge_rate must be >= 0, got {limit_charge_rate}")
549+
550+
# Always set TimeOfUse rule for mode 8 (even if 0 = no charging)
551+
timeofuselist = [{'Active': True,
552+
'Power': int(limit_charge_rate),
553+
'ScheduleType': 'CHARGE_MAX',
554+
"TimeTable": {"Start": "00:00", "End": "23:59"},
555+
"Weekdays":
556+
{"Mon": True,
557+
"Tue": True,
558+
"Wed": True,
559+
"Thu": True,
560+
"Fri": True,
561+
"Sat": True,
562+
"Sun": True}
563+
}]
564+
response = self.set_time_of_use(timeofuselist)
565+
return response
566+
541567
def set_mode_force_charge(self, chargerate=500):
542568
""" Set the inverter to charge the battery with a specific power from GRID."""
543569
# activate timeofuse rules

src/batcontrol/inverter/inverter_interface.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,19 @@ def set_mode_force_charge(self, chargerate: float):
1414

1515
@abstractmethod
1616
def set_mode_avoid_discharge(self):
17-
""" Set the inverter to allow discharge mode """
17+
""" Set the inverter to avoid discharge mode """
1818

1919
@abstractmethod
2020
def set_mode_allow_discharge(self):
21-
""" Set the inverter to avoid discharge mode """
21+
""" Set the inverter to allow discharge mode """
22+
23+
@abstractmethod
24+
def set_mode_limit_battery_charge(self, limit_charge_rate: int):
25+
""" Set the inverter to limit PV charging while allowing discharge
26+
27+
Args:
28+
limit_charge_rate: Maximum charge rate in W (0 = no charging)
29+
"""
2230

2331
@abstractmethod
2432
def get_stored_energy(self) -> float:

src/batcontrol/inverter/mqtt_inverter.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -372,6 +372,34 @@ def set_mode_avoid_discharge(self):
372372
retain=False
373373
)
374374

375+
def set_mode_limit_battery_charge(self, limit_charge_rate: int):
376+
"""
377+
Set inverter to limit battery charge rate mode.
378+
379+
Publishes mode and max charge rate to MQTT command topics (non-retained).
380+
381+
Args:
382+
limit_charge_rate: Maximum charge rate in W (0 = no charging)
383+
"""
384+
self.last_mode = 'limit_battery_charge'
385+
logger.info('Setting mode to limit_battery_charge with max rate %sW', limit_charge_rate)
386+
387+
# Publish mode command (QoS 1, not retained)
388+
self.mqtt_client.publish(
389+
f'{self.inverter_topic}/command/mode',
390+
'limit_battery_charge',
391+
qos=1,
392+
retain=False
393+
)
394+
395+
# Publish max charge rate command (QoS 1, not retained)
396+
self.mqtt_client.publish(
397+
f'{self.inverter_topic}/command/limit_battery_charge_rate',
398+
str(limit_charge_rate),
399+
qos=1,
400+
retain=False
401+
)
402+
375403
def get_capacity(self):
376404
"""
377405
Get battery capacity in Wh.

src/batcontrol/inverter/resilient_wrapper.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -400,6 +400,16 @@ def set_mode_allow_discharge(self):
400400
mark_initialized=True
401401
)
402402

403+
def set_mode_limit_battery_charge(self, limit_charge_rate: int):
404+
"""Set limit battery charge mode with resilience handling."""
405+
return self._call_with_resilience(
406+
self._inverter.set_mode_limit_battery_charge,
407+
"set_mode_limit_battery_charge",
408+
None, None,
409+
method_args=(limit_charge_rate,),
410+
mark_initialized=True
411+
)
412+
403413
# =========================================================================
404414
# InverterInterface Implementation - Other Methods
405415
# =========================================================================

src/batcontrol/mqtt_api.py

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,14 @@
66
- /status: online/offline status of batcontrol
77
- /evaluation_intervall: interval in seconds
88
- /last_evaluation: timestamp of last evaluation
9-
- /mode: operational mode (-1 = charge from grid, 0 = avoid discharge, 10 = discharge allowed)
9+
- /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 %
1212
- /always_allow_discharge_limit: always discharge limit in 0.1-1
1313
- /always_allow_discharge_limit_percent: always discharge limit in %
1414
- /always_allow_discharge_limit_capacity: always discharge limit in Wh
1515
- /charge_rate: charge rate in W
16+
- /limit_battery_charge_rate: dynamic battery charge rate limit in W
1617
- /max_energy_capacity: maximum capacity of battery in Wh
1718
- /stored_energy_capacity: energy stored in battery in Wh
1819
- /stored_usable_energy_capacity: energy stored in battery in Wh and usable (min SOC considered)
@@ -29,8 +30,9 @@
2930
- /FCST/net_consumption: forecasted net consumption in W
3031
3132
Implemented Input-API:
32-
- /mode/set: set mode (-1 = charge from grid, 0 = avoid discharge, 10 = discharge allowed)
33+
- /mode/set: set mode (-1 = charge from grid, 0 = avoid discharge, 8 = limit battery charge, 10 = discharge allowed)
3334
- /charge_rate/set: set charge rate in W, sets mode to -1
35+
- /limit_battery_charge_rate/set: set dynamic battery charge rate limit in W
3436
- /always_allow_discharge_limit/set: set always discharge limit in 0.1-1
3537
- /max_charging_from_grid_limit/set: set charge limit in 0-1
3638
- /min_price_difference/set: set minimum price difference in EUR
@@ -191,6 +193,16 @@ def publish_charge_rate(self, rate: float) -> None:
191193
if self.client.is_connected():
192194
self.client.publish(self.base_topic + '/charge_rate', rate)
193195

196+
def publish_limit_battery_charge_rate(self, limit: int) -> None:
197+
""" Publish dynamic battery charge rate limit to MQTT
198+
/limit_battery_charge_rate
199+
"""
200+
if self.client.is_connected():
201+
self.client.publish(
202+
self.base_topic + '/limit_battery_charge_rate',
203+
limit
204+
)
205+
194206
def publish_production(
195207
self,
196208
production: np.ndarray,

0 commit comments

Comments
 (0)