Skip to content

Commit 655a63c

Browse files
authored
Add clamp/wrap/remap to template math functions (home-assistant#154537)
1 parent a2ade41 commit 655a63c

File tree

2 files changed

+320
-1
lines changed

2 files changed

+320
-1
lines changed

homeassistant/helpers/template/extensions/math.py

Lines changed: 116 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from functools import wraps
77
import math
88
import statistics
9-
from typing import TYPE_CHECKING, Any
9+
from typing import TYPE_CHECKING, Any, Literal
1010

1111
import jinja2
1212
from jinja2 import pass_environment
@@ -77,6 +77,10 @@ def __init__(self, environment: TemplateEnvironment) -> None:
7777
TemplateFunction(
7878
"bitwise_xor", self.bitwise_xor, as_global=True, as_filter=True
7979
),
80+
# Value constraint functions (as globals and filters)
81+
TemplateFunction("clamp", self.clamp, as_global=True, as_filter=True),
82+
TemplateFunction("wrap", self.wrap, as_global=True, as_filter=True),
83+
TemplateFunction("remap", self.remap, as_global=True, as_filter=True),
8084
],
8185
)
8286

@@ -327,3 +331,114 @@ def bitwise_or(first_value: Any, second_value: Any) -> Any:
327331
def bitwise_xor(first_value: Any, second_value: Any) -> Any:
328332
"""Perform a bitwise xor operation."""
329333
return first_value ^ second_value
334+
335+
@staticmethod
336+
def clamp(value: Any, min_value: Any, max_value: Any) -> Any:
337+
"""Filter and function to clamp a value between min and max bounds.
338+
339+
Constrains value to the range [min_value, max_value] (inclusive).
340+
"""
341+
try:
342+
value_num = float(value)
343+
min_value_num = float(min_value)
344+
max_value_num = float(max_value)
345+
except (ValueError, TypeError) as err:
346+
raise ValueError(
347+
f"function requires numeric arguments, "
348+
f"got {value=}, {min_value=}, {max_value=}"
349+
) from err
350+
return max(min_value_num, min(max_value_num, value_num))
351+
352+
@staticmethod
353+
def wrap(value: Any, min_value: Any, max_value: Any) -> Any:
354+
"""Filter and function to wrap a value within a range.
355+
356+
Wraps value cyclically within [min_value, max_value) (inclusive min, exclusive max).
357+
"""
358+
try:
359+
value_num = float(value)
360+
min_value_num = float(min_value)
361+
max_value_num = float(max_value)
362+
except (ValueError, TypeError) as err:
363+
raise ValueError(
364+
f"function requires numeric arguments, "
365+
f"got {value=}, {min_value=}, {max_value=}"
366+
) from err
367+
try:
368+
range_size = max_value_num - min_value_num
369+
return ((value_num - min_value_num) % range_size) + min_value_num
370+
except ZeroDivisionError: # be lenient: if the range is empty, just clamp
371+
return min_value_num
372+
373+
@staticmethod
374+
def remap(
375+
value: Any,
376+
in_min: Any,
377+
in_max: Any,
378+
out_min: Any,
379+
out_max: Any,
380+
*,
381+
steps: int = 0,
382+
edges: Literal["none", "clamp", "wrap", "mirror"] = "none",
383+
) -> Any:
384+
"""Filter and function to remap a value from one range to another.
385+
386+
Maps value from input range [in_min, in_max] to output range [out_min, out_max].
387+
388+
The steps parameter, if greater than 0, quantizes the output into
389+
the specified number of discrete steps.
390+
391+
The edges parameter controls how out-of-bounds input values are handled:
392+
- "none": No special handling; values outside the input range are extrapolated into the output range.
393+
- "clamp": Values outside the input range are clamped to the nearest boundary.
394+
- "wrap": Values outside the input range are wrapped around cyclically.
395+
- "mirror": Values outside the input range are mirrored back into the range.
396+
"""
397+
try:
398+
value_num = float(value)
399+
in_min_num = float(in_min)
400+
in_max_num = float(in_max)
401+
out_min_num = float(out_min)
402+
out_max_num = float(out_max)
403+
except (ValueError, TypeError) as err:
404+
raise ValueError(
405+
f"function requires numeric arguments, "
406+
f"got {value=}, {in_min=}, {in_max=}, {out_min=}, {out_max=}"
407+
) from err
408+
409+
# Apply edge behavior in original space for accuracy.
410+
if edges == "clamp":
411+
value_num = max(in_min_num, min(in_max_num, value_num))
412+
elif edges == "wrap":
413+
if in_min_num == in_max_num:
414+
raise ValueError(f"{in_min=} must not equal {in_max=}")
415+
416+
range_size = in_max_num - in_min_num # Validated against div0 above.
417+
value_num = ((value_num - in_min_num) % range_size) + in_min_num
418+
elif edges == "mirror":
419+
if in_min_num == in_max_num:
420+
raise ValueError(f"{in_min=} must not equal {in_max=}")
421+
422+
range_size = in_max_num - in_min_num # Validated against div0 above.
423+
# Determine which period we're in and whether it should be mirrored
424+
offset = value_num - in_min_num
425+
period = math.floor(offset / range_size)
426+
position_in_period = offset - (period * range_size)
427+
428+
if (period < 0) or (period % 2 != 0):
429+
position_in_period = range_size - position_in_period
430+
431+
value_num = in_min_num + position_in_period
432+
# Unknown "edges" values are left as-is; no use throwing an error.
433+
434+
steps = max(steps, 0)
435+
436+
if not steps and (in_min_num == out_min_num and in_max_num == out_max_num):
437+
return value_num # No remapping needed. Save some cycles and floating-point precision.
438+
439+
normalized = (value_num - in_min_num) / (in_max_num - in_min_num)
440+
441+
if steps:
442+
normalized = round(normalized * steps) / steps
443+
444+
return out_min_num + (normalized * (out_max_num - out_min_num))

tests/helpers/template/extensions/test_math.py

Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
from homeassistant.core import HomeAssistant
1010
from homeassistant.exceptions import TemplateError
11+
from homeassistant.helpers.template.extensions import MathExtension
1112

1213
from tests.helpers.template.helpers import render
1314

@@ -340,3 +341,206 @@ def test_min_max_attribute(hass: HomeAssistant, attribute) -> None:
340341
)
341342
== 3
342343
)
344+
345+
346+
def test_clamp(hass: HomeAssistant) -> None:
347+
"""Test clamp function."""
348+
# Test function and filter usage in templates.
349+
assert render(hass, "{{ clamp(15, 0, 10) }}") == 10.0
350+
assert render(hass, "{{ -5 | clamp(0, 10) }}") == 0.0
351+
352+
# Test basic clamping behavior
353+
assert MathExtension.clamp(5, 0, 10) == 5.0
354+
assert MathExtension.clamp(-5, 0, 10) == 0.0
355+
assert MathExtension.clamp(15, 0, 10) == 10.0
356+
assert MathExtension.clamp(0, 0, 10) == 0.0
357+
assert MathExtension.clamp(10, 0, 10) == 10.0
358+
359+
# Test with float values
360+
assert MathExtension.clamp(5.5, 0, 10) == 5.5
361+
assert MathExtension.clamp(5.5, 0.5, 10.5) == 5.5
362+
assert MathExtension.clamp(0.25, 0.5, 10.5) == 0.5
363+
assert MathExtension.clamp(11.0, 0.5, 10.5) == 10.5
364+
365+
# Test with negative ranges
366+
assert MathExtension.clamp(-5, -10, -1) == -5.0
367+
assert MathExtension.clamp(-15, -10, -1) == -10.0
368+
assert MathExtension.clamp(0, -10, -1) == -1.0
369+
370+
# Test with non-range
371+
assert MathExtension.clamp(5, 10, 10) == 10.0
372+
373+
# Test error handling - invalid input types
374+
for case in (
375+
"{{ clamp('invalid', 0, 10) }}",
376+
"{{ clamp(5, 'invalid', 10) }}",
377+
"{{ clamp(5, 0, 'invalid') }}",
378+
):
379+
with pytest.raises(TemplateError):
380+
render(hass, case)
381+
382+
383+
def test_wrap(hass: HomeAssistant) -> None:
384+
"""Test wrap function."""
385+
# Test function and filter usage in templates.
386+
assert render(hass, "{{ wrap(15, 0, 10) }}") == 5.0
387+
assert render(hass, "{{ -5 | wrap(0, 10) }}") == 5.0
388+
389+
# Test basic wrapping behavior
390+
assert MathExtension.wrap(5, 0, 10) == 5.0
391+
assert MathExtension.wrap(10, 0, 10) == 0.0 # max wraps to min
392+
assert MathExtension.wrap(15, 0, 10) == 5.0
393+
assert MathExtension.wrap(25, 0, 10) == 5.0
394+
assert MathExtension.wrap(-5, 0, 10) == 5.0
395+
assert MathExtension.wrap(-10, 0, 10) == 0.0
396+
397+
# Test angle wrapping (common use case)
398+
assert MathExtension.wrap(370, 0, 360) == 10.0
399+
assert MathExtension.wrap(-10, 0, 360) == 350.0
400+
assert MathExtension.wrap(720, 0, 360) == 0.0
401+
assert MathExtension.wrap(361, 0, 360) == 1.0
402+
403+
# Test with float values
404+
assert MathExtension.wrap(10.5, 0, 10) == 0.5
405+
assert MathExtension.wrap(370.5, 0, 360) == 10.5
406+
407+
# Test with negative ranges
408+
assert MathExtension.wrap(-15, -10, 0) == -5.0
409+
assert MathExtension.wrap(5, -10, 0) == -5.0
410+
411+
# Test with arbitrary ranges
412+
assert MathExtension.wrap(25, 10, 20) == 15.0
413+
assert MathExtension.wrap(5, 10, 20) == 15.0
414+
415+
# Test with non-range
416+
assert MathExtension.wrap(5, 10, 10) == 10.0
417+
418+
# Test error handling - invalid input types
419+
for case in (
420+
"{{ wrap('invalid', 0, 10) }}",
421+
"{{ wrap(5, 'invalid', 10) }}",
422+
"{{ wrap(5, 0, 'invalid') }}",
423+
):
424+
with pytest.raises(TemplateError):
425+
render(hass, case)
426+
427+
428+
def test_remap(hass: HomeAssistant) -> None:
429+
"""Test remap function."""
430+
# Test function and filter usage in templates, with kitchen sink parameters.
431+
# We don't check the return value; that's covered by the unit tests below.
432+
assert render(hass, "{{ remap(5, 0, 6, 0, 740, steps=10) }}")
433+
assert render(hass, "{{ 50 | remap(0, 100, 0, 10, steps=8) }}")
434+
435+
# Test basic remapping - scale from 0-10 to 0-100
436+
assert MathExtension.remap(0, 0, 10, 0, 100) == 0.0
437+
assert MathExtension.remap(5, 0, 10, 0, 100) == 50.0
438+
assert MathExtension.remap(10, 0, 10, 0, 100) == 100.0
439+
440+
# Test with different input and output ranges
441+
assert MathExtension.remap(50, 0, 100, 0, 10) == 5.0
442+
assert MathExtension.remap(25, 0, 100, 0, 10) == 2.5
443+
444+
# Test with negative ranges
445+
assert MathExtension.remap(0, -10, 10, 0, 100) == 50.0
446+
assert MathExtension.remap(-10, -10, 10, 0, 100) == 0.0
447+
assert MathExtension.remap(10, -10, 10, 0, 100) == 100.0
448+
449+
# Test inverted output range
450+
assert MathExtension.remap(0, 0, 10, 100, 0) == 100.0
451+
assert MathExtension.remap(5, 0, 10, 100, 0) == 50.0
452+
assert MathExtension.remap(10, 0, 10, 100, 0) == 0.0
453+
454+
# Test values outside input range, and edge modes
455+
assert MathExtension.remap(15, 0, 10, 0, 100, edges="none") == 150.0
456+
assert MathExtension.remap(-4, 0, 10, 0, 100, edges="none") == -40.0
457+
assert MathExtension.remap(15, 0, 10, 0, 80, edges="clamp") == 80.0
458+
assert MathExtension.remap(-5, 0, 10, -1, 1, edges="clamp") == -1
459+
assert MathExtension.remap(15, 0, 10, 0, 100, edges="wrap") == 50.0
460+
assert MathExtension.remap(-5, 0, 10, 0, 100, edges="wrap") == 50.0
461+
462+
# Test sensor conversion use case: Celsius to Fahrenheit: 0-100°C to 32-212°F
463+
assert MathExtension.remap(0, 0, 100, 32, 212) == 32.0
464+
assert MathExtension.remap(100, 0, 100, 32, 212) == 212.0
465+
assert MathExtension.remap(50, 0, 100, 32, 212) == 122.0
466+
467+
# Test time conversion use case: 0-60 minutes to 0-360 degrees, with wrap
468+
assert MathExtension.remap(80, 0, 60, 0, 360, edges="wrap") == 120.0
469+
470+
# Test percentage to byte conversion (0-100% to 0-255)
471+
assert MathExtension.remap(0, 0, 100, 0, 255) == 0.0
472+
assert MathExtension.remap(50, 0, 100, 0, 255) == 127.5
473+
assert MathExtension.remap(100, 0, 100, 0, 255) == 255.0
474+
475+
# Test with float precision
476+
assert MathExtension.remap(2.5, 0, 10, 0, 100) == 25.0
477+
assert MathExtension.remap(7.5, 0, 10, 0, 100) == 75.0
478+
479+
# Test error handling
480+
for case in (
481+
"{{ remap(5, 10, 10, 0, 100) }}",
482+
"{{ remap('invalid', 0, 10, 0, 100) }}",
483+
"{{ remap(5, 'invalid', 10, 0, 100) }}",
484+
"{{ remap(5, 0, 'invalid', 0, 100) }}",
485+
"{{ remap(5, 0, 10, 'invalid', 100) }}",
486+
"{{ remap(5, 0, 10, 0, 'invalid') }}",
487+
):
488+
with pytest.raises(TemplateError):
489+
render(hass, case)
490+
491+
492+
def test_remap_with_steps(hass: HomeAssistant) -> None:
493+
"""Test remap function with steps parameter."""
494+
# Test basic stepping - quantize to 10 steps
495+
assert MathExtension.remap(0.2, 0, 10, 0, 100, steps=10) == 0.0
496+
assert MathExtension.remap(5.3, 0, 10, 0, 100, steps=10) == 50.0
497+
assert MathExtension.remap(10, 0, 10, 0, 100, steps=10) == 100.0
498+
499+
# Test stepping with intermediate values - should snap to nearest step
500+
# With 10 steps, normalized values are rounded: 0.0, 0.1, 0.2, ..., 1.0
501+
assert MathExtension.remap(2.4, 0, 10, 0, 100, steps=10) == 20.0
502+
assert MathExtension.remap(2.5, 0, 10, 0, 100, steps=10) == 20.0
503+
assert MathExtension.remap(2.6, 0, 10, 0, 100, steps=10) == 30.0
504+
505+
# Test with 4 steps (0%, 25%, 50%, 75%, 100%)
506+
assert MathExtension.remap(0, 0, 10, 0, 100, steps=4) == 0.0
507+
assert MathExtension.remap(2.5, 0, 10, 0, 100, steps=4) == 25.0
508+
assert MathExtension.remap(5, 0, 10, 0, 100, steps=4) == 50.0
509+
assert MathExtension.remap(7.5, 0, 10, 0, 100, steps=4) == 75.0
510+
assert MathExtension.remap(10, 0, 10, 0, 100, steps=4) == 100.0
511+
512+
# Test with 2 steps (0%, 50%, 100%)
513+
assert MathExtension.remap(2, 0, 10, 0, 100, steps=2) == 0.0
514+
assert MathExtension.remap(6, 0, 10, 0, 100, steps=2) == 50.0
515+
assert MathExtension.remap(8, 0, 10, 0, 100, steps=2) == 100.0
516+
517+
# Test with 1 step (0%, 100%)
518+
assert MathExtension.remap(0, 0, 10, 0, 100, steps=1) == 0.0
519+
assert MathExtension.remap(5, 0, 10, 0, 100, steps=1) == 0.0
520+
assert MathExtension.remap(6, 0, 10, 0, 100, steps=1) == 100.0
521+
assert MathExtension.remap(10, 0, 10, 0, 100, steps=1) == 100.0
522+
523+
# Test with inverted output range and steps
524+
assert MathExtension.remap(4.8, 0, 10, 100, 0, steps=4) == 50.0
525+
526+
# Test with 0 or negative steps (should be ignored/no quantization)
527+
assert MathExtension.remap(5, 0, 10, 0, 100, steps=0) == 50.0
528+
assert MathExtension.remap(2.7, 0, 10, 0, 100, steps=0) == 27.0
529+
assert MathExtension.remap(5, 0, 10, 0, 100, steps=-1) == 50.0
530+
531+
532+
def test_remap_with_mirror(hass: HomeAssistant) -> None:
533+
"""Test the mirror edge mode of the remap function."""
534+
535+
assert [
536+
MathExtension.remap(i, 0, 4, 0, 1, edges="mirror") for i in range(-4, 9)
537+
] == [1.0, 0.75, 0.5, 0.25, 0.0, 0.25, 0.5, 0.75, 1.0, 0.75, 0.5, 0.25, 0.0]
538+
539+
# Test with different output range
540+
assert MathExtension.remap(15, 0, 10, 50, 150, edges="mirror") == 100.0
541+
assert MathExtension.remap(25, 0, 10, 50, 150, edges="mirror") == 100.0
542+
# Test with inverted output range
543+
assert MathExtension.remap(15, 0, 10, 100, 0, edges="mirror") == 50.0
544+
assert MathExtension.remap(12, 0, 10, 100, 0, edges="mirror") == 20.0
545+
# Test without remapping
546+
assert MathExtension.remap(-0.1, 0, 1, 0, 1, edges="mirror") == pytest.approx(0.1)

0 commit comments

Comments
 (0)