Skip to content

Commit 2133b5f

Browse files
committed
feat: Added support for weighting for target timeframes not in minimum and maximum mode (3 hours dev time)
1 parent f039ed7 commit 2133b5f

12 files changed

Lines changed: 164 additions & 57 deletions

_docs/setup/rolling_target_timeframe.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,7 @@ There may be times that you want the target timeframe sensors to not take into a
100100

101101
!!! info
102102

103-
This is only available for **continuous** target value sensors in **exact** hours mode.
103+
This is only available for **continuous** target value sensors.
104104

105105
There may be times when the device you're wanting the target value sensor to turn on doesn't have a consistent power draw. You can specify a weighting/multiplier which can be applied to the value of each discovered 30 minute slot. This can be specified in a few different ways. Take the following example weighting/multiplier for a required 2 hours.
106106

_docs/setup/target_timeframe.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,7 @@ There may be times that you want the target timeframe sensors to not take into a
128128

129129
!!! info
130130

131-
This is only available for **continuous** target value sensors in **exact** hours mode.
131+
This is only available for **continuous** target value sensors.
132132

133133
There may be times when the device you're wanting the target value sensor to turn on doesn't have a consistent power draw. You can specify a weighting/multiplier which can be applied to the value of each discovered 30 minute slot. This can be specified in a few different ways. Take the following example weighting/multiplier for a required 2 hours.
134134

custom_components/target_timeframes/config/rolling_target_timeframe.py

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
CONFIG_TARGET_HOURS,
77
CONFIG_TARGET_HOURS_MODE,
88
CONFIG_TARGET_HOURS_MODE_EXACT,
9+
CONFIG_TARGET_HOURS_MODE_MAXIMUM,
910
CONFIG_TARGET_HOURS_MODE_MINIMUM,
1011
CONFIG_TARGET_MAX_VALUE,
1112
CONFIG_TARGET_MIN_VALUE,
@@ -120,14 +121,19 @@ def validate_rolling_target_timeframe_config(data):
120121
number_of_slots = int(data[CONFIG_TARGET_HOURS] * 2)
121122
weighting = create_weighting(data[CONFIG_TARGET_WEIGHTING], number_of_slots)
122123

123-
if (len(weighting) != number_of_slots):
124-
errors[CONFIG_TARGET_WEIGHTING] = "invalid_weighting_slots"
124+
if (weighting is None or len(weighting) != number_of_slots):
125+
if CONFIG_TARGET_HOURS_MODE in data and data[CONFIG_TARGET_HOURS_MODE] == CONFIG_TARGET_HOURS_MODE_MINIMUM:
126+
errors[CONFIG_TARGET_WEIGHTING] = "invalid_minimum_weighting_slots"
127+
elif CONFIG_TARGET_HOURS_MODE in data and data[CONFIG_TARGET_HOURS_MODE] == CONFIG_TARGET_HOURS_MODE_MAXIMUM:
128+
errors[CONFIG_TARGET_WEIGHTING] = "invalid_maximum_weighting_slots"
129+
else:
130+
errors[CONFIG_TARGET_WEIGHTING] = "invalid_weighting_slots"
131+
132+
if CONFIG_TARGET_HOURS_MODE in data and data[CONFIG_TARGET_HOURS_MODE] != CONFIG_TARGET_HOURS_MODE_EXACT and "*" not in data[CONFIG_TARGET_WEIGHTING]:
133+
errors[CONFIG_TARGET_WEIGHTING] = "weighting_not_varied_for_hour_mode"
125134

126135
if data[CONFIG_TARGET_TYPE] != CONFIG_TARGET_TYPE_CONTINUOUS:
127136
errors[CONFIG_TARGET_WEIGHTING] = "weighting_not_supported_for_type"
128-
129-
if CONFIG_TARGET_HOURS_MODE in data and data[CONFIG_TARGET_HOURS_MODE] != CONFIG_TARGET_HOURS_MODE_EXACT:
130-
errors[CONFIG_TARGET_WEIGHTING] = "weighting_not_supported_for_hour_mode"
131137

132138
if CONFIG_TARGET_HOURS_MODE in data and data[CONFIG_TARGET_HOURS_MODE] == CONFIG_TARGET_HOURS_MODE_MINIMUM:
133139
if (CONFIG_TARGET_MIN_VALUE not in data or data[CONFIG_TARGET_MIN_VALUE] is None) and (CONFIG_TARGET_MAX_VALUE not in data or data[CONFIG_TARGET_MAX_VALUE] is None):

custom_components/target_timeframes/config/target_timeframe.py

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
CONFIG_TARGET_HOURS,
1010
CONFIG_TARGET_HOURS_MODE,
1111
CONFIG_TARGET_HOURS_MODE_EXACT,
12+
CONFIG_TARGET_HOURS_MODE_MAXIMUM,
1213
CONFIG_TARGET_HOURS_MODE_MINIMUM,
1314
CONFIG_TARGET_MAX_VALUE,
1415
CONFIG_TARGET_MIN_VALUE,
@@ -146,14 +147,19 @@ def validate_target_timeframe_config(data):
146147
number_of_slots = int(data[CONFIG_TARGET_HOURS] * 2)
147148
weighting = create_weighting(data[CONFIG_TARGET_WEIGHTING], number_of_slots)
148149

149-
if (len(weighting) != number_of_slots):
150-
errors[CONFIG_TARGET_WEIGHTING] = "invalid_weighting_slots"
150+
if (weighting is None or len(weighting) != number_of_slots):
151+
if CONFIG_TARGET_HOURS_MODE in data and data[CONFIG_TARGET_HOURS_MODE] == CONFIG_TARGET_HOURS_MODE_MINIMUM:
152+
errors[CONFIG_TARGET_WEIGHTING] = "invalid_minimum_weighting_slots"
153+
elif CONFIG_TARGET_HOURS_MODE in data and data[CONFIG_TARGET_HOURS_MODE] == CONFIG_TARGET_HOURS_MODE_MAXIMUM:
154+
errors[CONFIG_TARGET_WEIGHTING] = "invalid_maximum_weighting_slots"
155+
else:
156+
errors[CONFIG_TARGET_WEIGHTING] = "invalid_weighting_slots"
157+
158+
if CONFIG_TARGET_HOURS_MODE in data and data[CONFIG_TARGET_HOURS_MODE] != CONFIG_TARGET_HOURS_MODE_EXACT and "*" not in data[CONFIG_TARGET_WEIGHTING]:
159+
errors[CONFIG_TARGET_WEIGHTING] = "weighting_not_varied_for_hour_mode"
151160

152161
if data[CONFIG_TARGET_TYPE] != CONFIG_TARGET_TYPE_CONTINUOUS:
153162
errors[CONFIG_TARGET_WEIGHTING] = "weighting_not_supported_for_type"
154-
155-
if CONFIG_TARGET_HOURS_MODE in data and data[CONFIG_TARGET_HOURS_MODE] != CONFIG_TARGET_HOURS_MODE_EXACT:
156-
errors[CONFIG_TARGET_WEIGHTING] = "weighting_not_supported_for_hour_mode"
157163

158164
if CONFIG_TARGET_HOURS_MODE in data and data[CONFIG_TARGET_HOURS_MODE] == CONFIG_TARGET_HOURS_MODE_MINIMUM:
159165
if (CONFIG_TARGET_MIN_VALUE not in data or data[CONFIG_TARGET_MIN_VALUE] is None) and (CONFIG_TARGET_MAX_VALUE not in data or data[CONFIG_TARGET_MAX_VALUE] is None):

custom_components/target_timeframes/entities/__init__.py

Lines changed: 24 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -141,7 +141,7 @@ def calculate_continuous_times(
141141
find_latest_values = False,
142142
min_value = None,
143143
max_value = None,
144-
weighting: list = None,
144+
weighting: str = None,
145145
hours_mode = CONFIG_TARGET_HOURS_MODE_EXACT,
146146
context: str = None
147147
):
@@ -151,9 +151,6 @@ def calculate_continuous_times(
151151
applicable_time_periods_count = len(applicable_time_periods)
152152
total_required_time_periods = math.ceil(target_hours * 2)
153153

154-
if weighting is not None and len(weighting) != total_required_time_periods:
155-
raise ValueError(f"{context} - Weighting does not match target hours")
156-
157154
best_continuous_time_periods = None
158155
best_continuous_time_periods_total = None
159156

@@ -169,8 +166,6 @@ def calculate_continuous_times(
169166
continue
170167

171168
continuous_time_periods = [time_period]
172-
value_weight = Decimal(time_period["weighting"]) if "weighting" in time_period else 1
173-
continuous_rates_total = Decimal(time_period["value"]) * value_weight * (weighting[0] if weighting is not None and len(weighting) > 0 else 1)
174169

175170
for offset in range(1, total_required_time_periods if hours_mode != CONFIG_TARGET_HOURS_MODE_MINIMUM else applicable_time_periods_count):
176171
if (index + offset) < applicable_time_periods_count:
@@ -183,21 +178,12 @@ def calculate_continuous_times(
183178
break
184179

185180
continuous_time_periods.append(offset_time_period)
186-
value_weight = Decimal(offset_time_period["weighting"]) if "weighting" in offset_time_period else 1
187-
continuous_rates_total += Decimal(offset_time_period["value"]) * value_weight * (weighting[offset] if weighting is not None else 1)
188181
else:
189182
break
190183

191184
current_continuous_time_periods_length = len(continuous_time_periods)
192185
best_continuous_time_periods_length = len(best_continuous_time_periods) if best_continuous_time_periods is not None else 0
193186

194-
is_best_continuous_rates = False
195-
if best_continuous_time_periods is not None:
196-
if search_for_highest_value:
197-
is_best_continuous_rates = (continuous_rates_total >= best_continuous_time_periods_total if find_latest_values else continuous_rates_total > best_continuous_time_periods_total)
198-
else:
199-
is_best_continuous_rates = (continuous_rates_total <= best_continuous_time_periods_total if find_latest_values else continuous_rates_total < best_continuous_time_periods_total)
200-
201187
has_required_hours = False
202188
if hours_mode == CONFIG_TARGET_HOURS_MODE_EXACT:
203189
has_required_hours = current_continuous_time_periods_length == total_required_time_periods
@@ -206,12 +192,26 @@ def calculate_continuous_times(
206192
elif hours_mode == CONFIG_TARGET_HOURS_MODE_MAXIMUM:
207193
has_required_hours = current_continuous_time_periods_length <= total_required_time_periods and current_continuous_time_periods_length >= best_continuous_time_periods_length
208194

209-
if ((best_continuous_time_periods is None or is_best_continuous_rates) and has_required_hours):
210-
best_continuous_time_periods = continuous_time_periods
211-
best_continuous_time_periods_total = continuous_rates_total
212-
_LOGGER.debug(f'{context} - New best block discovered {continuous_rates_total} ({continuous_time_periods[0]["start"] if len(continuous_time_periods) > 0 else None} - {continuous_time_periods[-1]["end"] if len(continuous_time_periods) > 0 else None})')
213-
else:
214-
_LOGGER.debug(f'{context} - Total rates for current block {continuous_rates_total} ({continuous_time_periods[0]["start"] if len(continuous_time_periods) > 0 else None} - {continuous_time_periods[-1]["end"] if len(continuous_time_periods) > 0 else None}). Total rates for best block {best_continuous_time_periods_total}')
195+
if has_required_hours:
196+
weighting_values = create_weighting(weighting, len(continuous_time_periods))
197+
if weighting_values is not None:
198+
continuous_rates_total = sum([Decimal(rate["value"]) * weighting_values[index] for index, rate in enumerate(continuous_time_periods)])
199+
else:
200+
continuous_rates_total = sum([Decimal(rate["value"]) for rate in continuous_time_periods])
201+
202+
is_best_continuous_rates = False
203+
if best_continuous_time_periods is not None:
204+
if search_for_highest_value:
205+
is_best_continuous_rates = (continuous_rates_total >= best_continuous_time_periods_total if find_latest_values else continuous_rates_total > best_continuous_time_periods_total)
206+
else:
207+
is_best_continuous_rates = (continuous_rates_total <= best_continuous_time_periods_total if find_latest_values else continuous_rates_total < best_continuous_time_periods_total)
208+
209+
if is_best_continuous_rates or best_continuous_time_periods is None:
210+
best_continuous_time_periods = continuous_time_periods
211+
best_continuous_time_periods_total = continuous_rates_total
212+
_LOGGER.debug(f'{context} - New best block discovered {continuous_rates_total} ({continuous_time_periods[0]["start"] if len(continuous_time_periods) > 0 else None} - {continuous_time_periods[-1]["end"] if len(continuous_time_periods) > 0 else None})')
213+
else:
214+
_LOGGER.debug(f'{context} - Total rates for current block {continuous_rates_total} ({continuous_time_periods[0]["start"] if len(continuous_time_periods) > 0 else None} - {continuous_time_periods[-1]["end"] if len(continuous_time_periods) > 0 else None}). Total rates for best block {best_continuous_time_periods_total}')
215215

216216
if best_continuous_time_periods is not None:
217217
# Make sure our rates are in ascending order before returning
@@ -409,6 +409,9 @@ def create_weighting(config: str, number_of_slots: int):
409409

410410
parts = config.split(',')
411411
parts_length = len(parts)
412+
if parts_length > number_of_slots:
413+
return None
414+
412415
weighting = []
413416
for index in range(parts_length):
414417
if (parts[index] == "*"):

custom_components/target_timeframes/entities/rolling_target_timeframe.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -163,8 +163,7 @@ async def async_update(self):
163163
)
164164

165165
if applicable_time_periods is not None:
166-
number_of_slots = math.ceil(target_hours * 2)
167-
weighting = create_weighting(self._config[CONFIG_TARGET_WEIGHTING] if CONFIG_TARGET_WEIGHTING in self._config else None, number_of_slots)
166+
weighting = self._config[CONFIG_TARGET_WEIGHTING] if CONFIG_TARGET_WEIGHTING in self._config else None
168167

169168
if (self._config[CONFIG_TARGET_TYPE] == CONFIG_TARGET_TYPE_CONTINUOUS):
170169
self._target_timeframes = calculate_continuous_times(

custom_components/target_timeframes/entities/target_timeframe.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -195,8 +195,7 @@ async def async_update(self):
195195
)
196196

197197
if applicable_time_periods is not None and is_target_timeframe_complete == False:
198-
number_of_slots = math.ceil(target_hours * 2)
199-
weighting = create_weighting(self._config[CONFIG_TARGET_WEIGHTING] if CONFIG_TARGET_WEIGHTING in self._config else None, number_of_slots)
198+
weighting = self._config[CONFIG_TARGET_WEIGHTING] if CONFIG_TARGET_WEIGHTING in self._config else None
200199

201200
proposed_target_timeframes = None
202201
if (self._config[CONFIG_TARGET_TYPE] == CONFIG_TARGET_TYPE_CONTINUOUS):

custom_components/target_timeframes/translations/en.json

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -130,8 +130,10 @@
130130
"invalid_value": "Value must be in decimal format (e.g. 0.10)",
131131
"invalid_weighting": "The weighting format is not supported. Please consult documentation for more information.",
132132
"invalid_weighting_slots": "The number of weighting blocks does not equal the specified number of hours.",
133+
"invalid_minimum_weighting_slots": "The number of weighting blocks is higher than the specified number of minimum hours.",
134+
"invalid_maximum_weighting_slots": "The number of weighting blocks is higher than the specified number of maximum hours.",
135+
"weighting_not_varied_for_hour_mode": "The weighting must include a variable block (*) if the hour mode is not exact",
133136
"weighting_not_supported_for_type": "Weighting is only supported for continuous target values",
134-
"weighting_not_supported_for_hour_mode": "Weighting is not supported for this hour mode",
135137
"minimum_or_maximum_value_not_specified": "Either minimum and/or maximum value must be specified for minimum hours mode",
136138
"minimum_value_not_less_than_maximum_value": "Minimum value must be less or equal to the maximum value if both are specified",
137139
"invalid_integer": "Value must be a number with no decimal places",
@@ -212,8 +214,10 @@
212214
"invalid_value": "Value must be in decimal format (e.g. 0.10)",
213215
"invalid_weighting": "The weighting format is not supported. Please consult documentation for more information.",
214216
"invalid_weighting_slots": "The number of weighting blocks does not equal the specified number of hours.",
217+
"invalid_minimum_weighting_slots": "The number of weighting blocks is higher than the specified number of minimum hours.",
218+
"invalid_maximum_weighting_slots": "The number of weighting blocks is higher than the specified number of maximum hours.",
219+
"weighting_not_varied_for_hour_mode": "The weighting must include a variable block (*) if the hour mode is not exact",
215220
"weighting_not_supported_for_type": "Weighting is only supported for continuous target values",
216-
"weighting_not_supported_for_hour_mode": "Weighting is not supported for this hour mode",
217221
"minimum_or_maximum_value_not_specified": "Either minimum and/or maximum value must be specified for minimum hours mode",
218222
"minimum_value_not_less_than_maximum_value": "Minimum value must be less or equal to the maximum value if both are specified",
219223
"invalid_integer": "Value must be a number with no decimal places",

tests/unit/config/test_validate_rolling_target_timeframe_config.py

Lines changed: 47 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -321,15 +321,15 @@ async def test_when_minimum_value_greater_to_maximum_value_is_specified_then_err
321321
(CONFIG_TARGET_HOURS_MODE_MINIMUM),
322322
(CONFIG_TARGET_HOURS_MODE_MAXIMUM),
323323
])
324-
async def test_when_hour_mode_is_not_exact_and_weighting_specified_then_error_returned(hour_mode: str):
324+
async def test_when_hour_mode_is_not_exact_and_weighting_is_not_varied_then_error_returned(hour_mode: str):
325325
# Arrange
326326
data = {
327327
CONFIG_TARGET_TYPE: CONFIG_TARGET_TYPE_CONTINUOUS,
328328
CONFIG_TARGET_NAME: "test",
329329
CONFIG_TARGET_HOURS: "1.5",
330330
CONFIG_ROLLING_TARGET_HOURS_LOOK_AHEAD: "2",
331331
CONFIG_TARGET_OFFSET: "-00:30:00",
332-
CONFIG_TARGET_WEIGHTING: "2,*,2",
332+
CONFIG_TARGET_WEIGHTING: "2,1,2,2",
333333
CONFIG_TARGET_MIN_VALUE: "0.18",
334334
CONFIG_TARGET_HOURS_MODE: hour_mode
335335
}
@@ -339,7 +339,51 @@ async def test_when_hour_mode_is_not_exact_and_weighting_specified_then_error_re
339339

340340
# Assert
341341
assert CONFIG_TARGET_WEIGHTING in errors
342-
assert errors[CONFIG_TARGET_WEIGHTING] == "weighting_not_supported_for_hour_mode"
342+
assert errors[CONFIG_TARGET_WEIGHTING] == "weighting_not_varied_for_hour_mode"
343+
assert_errors_not_present(errors, default_keys, CONFIG_TARGET_WEIGHTING)
344+
345+
@pytest.mark.asyncio
346+
async def test_when_hour_mode_is_minimum_and_weighting_too_small_then_error_returned():
347+
# Arrange
348+
data = {
349+
CONFIG_TARGET_TYPE: CONFIG_TARGET_TYPE_CONTINUOUS,
350+
CONFIG_TARGET_NAME: "test",
351+
CONFIG_TARGET_HOURS: "1.5",
352+
CONFIG_ROLLING_TARGET_HOURS_LOOK_AHEAD: "2",
353+
CONFIG_TARGET_OFFSET: "-00:30:00",
354+
CONFIG_TARGET_WEIGHTING: "2,*,2,2",
355+
CONFIG_TARGET_MIN_VALUE: "0.18",
356+
CONFIG_TARGET_HOURS_MODE: CONFIG_TARGET_HOURS_MODE_MINIMUM
357+
}
358+
359+
# Act
360+
errors = validate_rolling_target_timeframe_config(data)
361+
362+
# Assert
363+
assert CONFIG_TARGET_WEIGHTING in errors
364+
assert errors[CONFIG_TARGET_WEIGHTING] == "invalid_minimum_weighting_slots"
365+
assert_errors_not_present(errors, default_keys, CONFIG_TARGET_WEIGHTING)
366+
367+
@pytest.mark.asyncio
368+
async def test_when_hour_mode_is_maximum_and_weighting_too_large_then_error_returned():
369+
# Arrange
370+
data = {
371+
CONFIG_TARGET_TYPE: CONFIG_TARGET_TYPE_CONTINUOUS,
372+
CONFIG_TARGET_NAME: "test",
373+
CONFIG_TARGET_HOURS: "1.5",
374+
CONFIG_ROLLING_TARGET_HOURS_LOOK_AHEAD: "2",
375+
CONFIG_TARGET_OFFSET: "-00:30:00",
376+
CONFIG_TARGET_WEIGHTING: "2,1,*,2,2",
377+
CONFIG_TARGET_MIN_VALUE: "0.18",
378+
CONFIG_TARGET_HOURS_MODE: CONFIG_TARGET_HOURS_MODE_MAXIMUM
379+
}
380+
381+
# Act
382+
errors = validate_rolling_target_timeframe_config(data)
383+
384+
# Assert
385+
assert CONFIG_TARGET_WEIGHTING in errors
386+
assert errors[CONFIG_TARGET_WEIGHTING] == "invalid_maximum_weighting_slots"
343387
assert_errors_not_present(errors, default_keys, CONFIG_TARGET_WEIGHTING)
344388

345389
@pytest.mark.asyncio

0 commit comments

Comments
 (0)