Skip to content

Commit 69a4794

Browse files
CopilotMaStr
andcommitted
Move always_allow_discharge_limit float conversion to core.py (Batcontrol main)
Co-authored-by: MaStr <1036501+MaStr@users.noreply.github.com>
1 parent a59212f commit 69a4794

4 files changed

Lines changed: 127 additions & 64 deletions

File tree

src/batcontrol/core.py

Lines changed: 34 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,23 @@
3838
DELAY_EVALUATION_BY_SECONDS = 15 # Delay evaluation for x seconds at every trigger
3939
# Interval between evaluations in seconds
4040
TIME_BETWEEN_EVALUATIONS = EVALUATIONS_EVERY_MINUTES * 60
41+
42+
43+
def _to_float(value) -> float:
44+
""" Convert a config or API value to float, handling European comma decimal notation.
45+
Args:
46+
value: The value to convert. Can be a float, int, or string
47+
(including European notation like '0,9').
48+
Returns:
49+
float: The converted value.
50+
Raises:
51+
ValueError: If the value cannot be converted to float.
52+
"""
53+
if isinstance(value, str):
54+
return float(value.replace(',', '.'))
55+
return float(value)
56+
57+
4158
TIME_BETWEEN_UTILITY_API_CALLS = 900 # 15 Minutes
4259
MIN_FORECAST_HOURS = 1 # Minimum required forecast hours
4360
FORECAST_TOLERANCE = 3 # Acceptable tolerance for forecast hours
@@ -60,7 +77,8 @@ def __init__(self, configdict: dict):
6077
# -1 = charge from grid , 0 = avoid discharge , 8 = limit battery charge, 10 = discharge allowed
6178
self.last_mode = None
6279
self.last_charge_rate = 0
63-
self._limit_battery_charge_rate = -1 # Dynamic battery charge rate limit (-1 = no limit)
80+
# Dynamic battery charge rate limit (-1 = no limit)
81+
self._limit_battery_charge_rate = -1
6482
self.last_prices = None
6583
self.last_consumption = None
6684
self.last_production = None
@@ -155,7 +173,8 @@ def __init__(self, configdict: dict):
155173
'max_pv_charge_rate',
156174
getattr(self.inverter, 'max_pv_charge_rate', 0),
157175
)
158-
self.min_pv_charge_rate = config['inverter'].get('min_pv_charge_rate', 0)
176+
self.min_pv_charge_rate = config['inverter'].get(
177+
'min_pv_charge_rate', 0)
159178

160179
# Validate min/max PV charge rate configuration at startup
161180
if (
@@ -214,8 +233,8 @@ def __init__(self, configdict: dict):
214233
self.general_logic = CommonLogic.get_instance(
215234
charge_rate_multiplier=self.batconfig.get(
216235
'charge_rate_multiplier', 1.1),
217-
always_allow_discharge_limit=self.batconfig.get(
218-
'always_allow_discharge_limit', 0.9),
236+
always_allow_discharge_limit=_to_float(self.batconfig.get(
237+
'always_allow_discharge_limit', 0.9)),
219238
max_capacity=self.inverter.get_max_capacity(),
220239
min_charge_energy=self.batconfig.get('min_recharge_amount', 100.0)
221240
)
@@ -363,7 +382,8 @@ def handle_forecast_error(self):
363382

364383
def run(self):
365384
""" Main calculation & control loop """
366-
logger.debug('Timeslots are in %d-minute intervals', self.time_resolution)
385+
logger.debug('Timeslots are in %d-minute intervals',
386+
self.time_resolution)
367387

368388
# Reset some values
369389
self.__reset_run_data()
@@ -542,7 +562,8 @@ def run(self):
542562

543563
if inverter_settings.allow_discharge:
544564
if inverter_settings.limit_battery_charge_rate >= 0:
545-
self.limit_battery_charge_rate(inverter_settings.limit_battery_charge_rate)
565+
self.limit_battery_charge_rate(
566+
inverter_settings.limit_battery_charge_rate)
546567
else:
547568
self.allow_discharging()
548569
elif inverter_settings.charge_from_grid:
@@ -611,7 +632,8 @@ def limit_battery_charge_rate(self, limit_charge_rate: int = 0):
611632
if self.min_pv_charge_rate > 0 and effective_limit > 0:
612633
effective_limit = max(effective_limit, self.min_pv_charge_rate)
613634

614-
logger.info('Mode: Limit Battery Charge Rate to %d W, discharge allowed', effective_limit)
635+
logger.info(
636+
'Mode: Limit Battery Charge Rate to %d W, discharge allowed', effective_limit)
615637
self.inverter.set_mode_limit_battery_charge(effective_limit)
616638
self.__set_mode(MODE_LIMIT_BATTERY_CHARGE_RATE)
617639

@@ -723,11 +745,10 @@ def set_discharge_limit(self, discharge_limit) -> None:
723745
def set_always_allow_discharge_limit(
724746
self, always_allow_discharge_limit: float) -> None:
725747
""" Set the always allow discharge limit for battery control """
726-
self.general_logic.set_always_allow_discharge_limit(
727-
always_allow_discharge_limit)
748+
limit = _to_float(always_allow_discharge_limit)
749+
self.general_logic.set_always_allow_discharge_limit(limit)
728750
if self.mqtt_api is not None:
729-
self.mqtt_api.publish_always_allow_discharge_limit(
730-
always_allow_discharge_limit)
751+
self.mqtt_api.publish_always_allow_discharge_limit(limit)
731752

732753
def get_always_allow_discharge_limit(self) -> float:
733754
""" Get the always allow discharge limit for battery control """
@@ -848,7 +869,8 @@ def api_set_limit_battery_charge_rate(self, limit: int):
848869
limit: Maximum battery charge rate in W (0 = no charging, -1 = no limit)
849870
"""
850871
if limit < -1:
851-
logger.warning('API: Invalid limit_battery_charge_rate %d W', limit)
872+
logger.warning(
873+
'API: Invalid limit_battery_charge_rate %d W', limit)
852874
return
853875

854876
logger.info('API: Setting limit_battery_charge_rate to %d W', limit)

src/batcontrol/logic/common.py

Lines changed: 14 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -16,17 +16,14 @@
1616
logger = logging.getLogger(__name__)
1717

1818
# Singleton pattern to ensure only one instance of CommonLogic exists
19-
20-
2119
class CommonLogic:
2220
""" General logic for battery control that is not specific to control strategies. """
2321

2422
_instance = None # Singleton instance
2523
charge_rate_multiplier: float
2624
always_allow_discharge_limit: float
2725
max_capacity: float # Maximum capacity of the battery in Wh
28-
# Minimum amount of energy before charging from grid in Wh
29-
min_charge_energy: float = 100
26+
min_charge_energy: float = 100 # Minimum amount of energy before charging from grid in Wh
3027

3128
@classmethod
3229
def get_instance(cls, charge_rate_multiplier=1.1,
@@ -37,9 +34,9 @@ def get_instance(cls, charge_rate_multiplier=1.1,
3734
if cls._instance is None:
3835
cls._instance = cls.__new__(cls)
3936
cls._instance.initialize(charge_rate_multiplier,
40-
always_allow_discharge_limit,
41-
max_capacity,
42-
min_charge_energy)
37+
always_allow_discharge_limit,
38+
max_capacity,
39+
min_charge_energy)
4340
return cls._instance
4441

4542
def __init__(self, *args, **kwargs):
@@ -50,41 +47,25 @@ def __init__(self, *args, **kwargs):
5047
self.initialize(*args, **kwargs)
5148

5249
def initialize(self, charge_rate_multiplier=1.1,
53-
always_allow_discharge_limit=0.9,
54-
max_capacity=10000,
55-
min_charge_energy=100):
50+
always_allow_discharge_limit=0.9,
51+
max_capacity=10000,
52+
min_charge_energy=100):
5653
""" Private initialization method. """
57-
self.charge_rate_multiplier = float(charge_rate_multiplier)
58-
self.always_allow_discharge_limit = self._to_float(
59-
always_allow_discharge_limit)
60-
self.max_capacity = float(max_capacity)
61-
self.min_charge_energy = float(min_charge_energy)
54+
self.charge_rate_multiplier = charge_rate_multiplier
55+
self.always_allow_discharge_limit = always_allow_discharge_limit
56+
self.max_capacity = max_capacity
57+
self.min_charge_energy = min_charge_energy
6258

6359
def set_charge_rate_multiplier(self, multiplier: float):
6460
""" Set the charge rate multiplier. """
6561
logger.debug('Setting charge rate multiplier to %s', multiplier)
6662
self.charge_rate_multiplier = multiplier
6763

68-
@staticmethod
69-
def _to_float(value) -> float:
70-
""" Convert a value to float, handling European comma decimal notation.
71-
Args:
72-
value: The value to convert. Can be a float, int, or string
73-
(including European notation like '0,9').
74-
Returns:
75-
float: The converted value.
76-
Raises:
77-
ValueError: If the value cannot be converted to float.
78-
"""
79-
if isinstance(value, str):
80-
return float(value.replace(',', '.'))
81-
return float(value)
82-
8364
def set_always_allow_discharge_limit(self, limit: float):
8465
""" Set the always allowed discharge limit. """
8566
logger.debug(
8667
'Setting always allowed discharge limit to %s', limit)
87-
self.always_allow_discharge_limit = self._to_float(limit)
68+
self.always_allow_discharge_limit = limit
8869

8970
def get_always_allow_discharge_limit(self) -> float:
9071
""" Get the always allowed discharge limit. """
@@ -111,11 +92,11 @@ def is_discharge_always_allowed_capacity(self, capacity: float) -> bool:
11192

11293
if capacity >= self.max_capacity * self.always_allow_discharge_limit:
11394
logger.debug(
114-
'Discharge is \'always allowed\' for current capacity: %.0f Wh', round(capacity, 0))
95+
'Discharge is \'always allowed\' for current capacity: %.0f Wh', round(capacity,0))
11596
return True
11697

11798
logger.debug(
118-
'Discharge is NOT \'always allowed\' for current capacity: %.0f Wh', round(capacity, 0))
99+
'Discharge is NOT \'always allowed\' for current capacity: %.0f Wh', round(capacity,0))
119100
return False
120101

121102
def is_charging_above_minimum(self, needed_energy: float) -> bool:

tests/batcontrol/logic/test_common.py

Lines changed: 0 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -43,25 +43,6 @@ def test_set_always_allowed_discharge_limit(self):
4343
self.assertEqual(self.logic.always_allow_discharge_limit, new_limit)
4444
self.assertEqual(self.logic.get_always_allow_discharge_limit(), new_limit)
4545

46-
def test_set_always_allowed_discharge_limit_string(self):
47-
"""Test setting the always allowed discharge limit with a string value (dot notation)"""
48-
self.logic.set_always_allow_discharge_limit('0.85')
49-
self.assertIsInstance(self.logic.always_allow_discharge_limit, float)
50-
self.assertAlmostEqual(self.logic.always_allow_discharge_limit, 0.85)
51-
52-
def test_set_always_allowed_discharge_limit_european_comma(self):
53-
"""Test setting the always allowed discharge limit with European comma notation"""
54-
self.logic.set_always_allow_discharge_limit('0,85')
55-
self.assertIsInstance(self.logic.always_allow_discharge_limit, float)
56-
self.assertAlmostEqual(self.logic.always_allow_discharge_limit, 0.85)
57-
58-
def test_initialize_always_allow_discharge_limit_from_string(self):
59-
"""Test that initialize converts always_allow_discharge_limit string to float"""
60-
CommonLogic._instance = None
61-
logic = CommonLogic.get_instance(always_allow_discharge_limit='0,75')
62-
self.assertIsInstance(logic.always_allow_discharge_limit, float)
63-
self.assertAlmostEqual(logic.always_allow_discharge_limit, 0.75)
64-
6546
def test_is_discharge_always_allowed_soc(self):
6647
"""Test discharge always allowed when SOC is above threshold"""
6748
# SOC above the threshold (90%)

tests/batcontrol/test_core.py

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -287,5 +287,84 @@ def test_api_set_limit_applies_immediately_in_mode_8(
287287
mock_inverter.set_mode_limit_battery_charge.assert_called_once_with(2000)
288288

289289

290+
class TestAlwaysAllowDischargeLimitConversion:
291+
"""Tests that always_allow_discharge_limit is always a float in core.py"""
292+
293+
@pytest.fixture
294+
def mock_inverter(self):
295+
"""Create a mock inverter"""
296+
inv = MagicMock()
297+
inv.max_pv_charge_rate = 3000
298+
inv.set_mode_limit_battery_charge = MagicMock()
299+
inv.get_max_capacity = MagicMock(return_value=10000)
300+
return inv
301+
302+
def _make_batcontrol(self, mock_inverter, always_allow_discharge_limit):
303+
"""Helper to create a Batcontrol instance with a given discharge limit config"""
304+
config = {
305+
'timezone': 'Europe/Berlin',
306+
'time_resolution_minutes': 60,
307+
'inverter': {
308+
'type': 'dummy',
309+
'max_grid_charge_rate': 5000,
310+
'max_pv_charge_rate': 3000,
311+
'min_pv_charge_rate': 100
312+
},
313+
'utility': {'type': 'tibber', 'token': 'test_token'},
314+
'pvinstallations': [],
315+
'consumption_forecast': {'type': 'simple', 'value': 500},
316+
'battery_control': {
317+
'max_charging_from_grid_limit': 0.5,
318+
'min_price_difference': 0.05,
319+
'always_allow_discharge_limit': always_allow_discharge_limit,
320+
},
321+
'mqtt': {'enabled': False}
322+
}
323+
with patch('batcontrol.core.tariff_factory.create_tarif_provider'), \
324+
patch('batcontrol.core.inverter_factory.create_inverter',
325+
return_value=mock_inverter), \
326+
patch('batcontrol.core.solar_factory.create_solar_provider'), \
327+
patch('batcontrol.core.consumption_factory.create_consumption'):
328+
from batcontrol.logic.common import CommonLogic
329+
CommonLogic._instance = None
330+
return Batcontrol(config)
331+
332+
def test_config_string_dot_notation(self, mock_inverter):
333+
"""Config value '0.9' (string) is converted to float 0.9"""
334+
bc = self._make_batcontrol(mock_inverter, '0.9')
335+
result = bc.get_always_allow_discharge_limit()
336+
assert isinstance(result, float)
337+
assert abs(result - 0.9) < 1e-9
338+
339+
def test_config_european_comma_notation(self, mock_inverter):
340+
"""Config value '0,9' (European decimal) is converted to float 0.9"""
341+
bc = self._make_batcontrol(mock_inverter, '0,9')
342+
result = bc.get_always_allow_discharge_limit()
343+
assert isinstance(result, float)
344+
assert abs(result - 0.9) < 1e-9
345+
346+
def test_setter_string_dot_notation(self, mock_inverter):
347+
"""Setter with string '0.85' is converted to float"""
348+
bc = self._make_batcontrol(mock_inverter, 0.9)
349+
bc.set_always_allow_discharge_limit('0.85')
350+
result = bc.get_always_allow_discharge_limit()
351+
assert isinstance(result, float)
352+
assert abs(result - 0.85) < 1e-9
353+
354+
def test_setter_european_comma_notation(self, mock_inverter):
355+
"""Setter with European '0,85' is converted to float"""
356+
bc = self._make_batcontrol(mock_inverter, 0.9)
357+
bc.set_always_allow_discharge_limit('0,85')
358+
result = bc.get_always_allow_discharge_limit()
359+
assert isinstance(result, float)
360+
assert abs(result - 0.85) < 1e-9
361+
362+
def test_getter_always_returns_float(self, mock_inverter):
363+
"""get_always_allow_discharge_limit always returns a float"""
364+
bc = self._make_batcontrol(mock_inverter, 0.9)
365+
result = bc.get_always_allow_discharge_limit()
366+
assert isinstance(result, float)
367+
368+
290369
if __name__ == '__main__':
291370
pytest.main([__file__, '-v'])

0 commit comments

Comments
 (0)