diff --git a/homeassistant/helpers/template/extensions/math.py b/homeassistant/helpers/template/extensions/math.py index 9ec7016399f..c4697b3a17c 100644 --- a/homeassistant/helpers/template/extensions/math.py +++ b/homeassistant/helpers/template/extensions/math.py @@ -6,7 +6,7 @@ from collections.abc import Iterable from functools import wraps import math import statistics -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, Literal import jinja2 from jinja2 import pass_environment @@ -77,6 +77,10 @@ class MathExtension(BaseTemplateExtension): TemplateFunction( "bitwise_xor", self.bitwise_xor, as_global=True, as_filter=True ), + # Value constraint functions (as globals and filters) + TemplateFunction("clamp", self.clamp, as_global=True, as_filter=True), + TemplateFunction("wrap", self.wrap, as_global=True, as_filter=True), + TemplateFunction("remap", self.remap, as_global=True, as_filter=True), ], ) @@ -327,3 +331,114 @@ class MathExtension(BaseTemplateExtension): def bitwise_xor(first_value: Any, second_value: Any) -> Any: """Perform a bitwise xor operation.""" return first_value ^ second_value + + @staticmethod + def clamp(value: Any, min_value: Any, max_value: Any) -> Any: + """Filter and function to clamp a value between min and max bounds. + + Constrains value to the range [min_value, max_value] (inclusive). + """ + try: + value_num = float(value) + min_value_num = float(min_value) + max_value_num = float(max_value) + except (ValueError, TypeError) as err: + raise ValueError( + f"function requires numeric arguments, " + f"got {value=}, {min_value=}, {max_value=}" + ) from err + return max(min_value_num, min(max_value_num, value_num)) + + @staticmethod + def wrap(value: Any, min_value: Any, max_value: Any) -> Any: + """Filter and function to wrap a value within a range. + + Wraps value cyclically within [min_value, max_value) (inclusive min, exclusive max). + """ + try: + value_num = float(value) + min_value_num = float(min_value) + max_value_num = float(max_value) + except (ValueError, TypeError) as err: + raise ValueError( + f"function requires numeric arguments, " + f"got {value=}, {min_value=}, {max_value=}" + ) from err + try: + range_size = max_value_num - min_value_num + return ((value_num - min_value_num) % range_size) + min_value_num + except ZeroDivisionError: # be lenient: if the range is empty, just clamp + return min_value_num + + @staticmethod + def remap( + value: Any, + in_min: Any, + in_max: Any, + out_min: Any, + out_max: Any, + *, + steps: int = 0, + edges: Literal["none", "clamp", "wrap", "mirror"] = "none", + ) -> Any: + """Filter and function to remap a value from one range to another. + + Maps value from input range [in_min, in_max] to output range [out_min, out_max]. + + The steps parameter, if greater than 0, quantizes the output into + the specified number of discrete steps. + + The edges parameter controls how out-of-bounds input values are handled: + - "none": No special handling; values outside the input range are extrapolated into the output range. + - "clamp": Values outside the input range are clamped to the nearest boundary. + - "wrap": Values outside the input range are wrapped around cyclically. + - "mirror": Values outside the input range are mirrored back into the range. + """ + try: + value_num = float(value) + in_min_num = float(in_min) + in_max_num = float(in_max) + out_min_num = float(out_min) + out_max_num = float(out_max) + except (ValueError, TypeError) as err: + raise ValueError( + f"function requires numeric arguments, " + f"got {value=}, {in_min=}, {in_max=}, {out_min=}, {out_max=}" + ) from err + + # Apply edge behavior in original space for accuracy. + if edges == "clamp": + value_num = max(in_min_num, min(in_max_num, value_num)) + elif edges == "wrap": + if in_min_num == in_max_num: + raise ValueError(f"{in_min=} must not equal {in_max=}") + + range_size = in_max_num - in_min_num # Validated against div0 above. + value_num = ((value_num - in_min_num) % range_size) + in_min_num + elif edges == "mirror": + if in_min_num == in_max_num: + raise ValueError(f"{in_min=} must not equal {in_max=}") + + range_size = in_max_num - in_min_num # Validated against div0 above. + # Determine which period we're in and whether it should be mirrored + offset = value_num - in_min_num + period = math.floor(offset / range_size) + position_in_period = offset - (period * range_size) + + if (period < 0) or (period % 2 != 0): + position_in_period = range_size - position_in_period + + value_num = in_min_num + position_in_period + # Unknown "edges" values are left as-is; no use throwing an error. + + steps = max(steps, 0) + + if not steps and (in_min_num == out_min_num and in_max_num == out_max_num): + return value_num # No remapping needed. Save some cycles and floating-point precision. + + normalized = (value_num - in_min_num) / (in_max_num - in_min_num) + + if steps: + normalized = round(normalized * steps) / steps + + return out_min_num + (normalized * (out_max_num - out_min_num)) diff --git a/tests/helpers/template/extensions/test_math.py b/tests/helpers/template/extensions/test_math.py index 0ee79f4e0a3..035e0adfbdc 100644 --- a/tests/helpers/template/extensions/test_math.py +++ b/tests/helpers/template/extensions/test_math.py @@ -8,6 +8,7 @@ import pytest from homeassistant.core import HomeAssistant from homeassistant.exceptions import TemplateError +from homeassistant.helpers.template.extensions import MathExtension from tests.helpers.template.helpers import render @@ -340,3 +341,206 @@ def test_min_max_attribute(hass: HomeAssistant, attribute) -> None: ) == 3 ) + + +def test_clamp(hass: HomeAssistant) -> None: + """Test clamp function.""" + # Test function and filter usage in templates. + assert render(hass, "{{ clamp(15, 0, 10) }}") == 10.0 + assert render(hass, "{{ -5 | clamp(0, 10) }}") == 0.0 + + # Test basic clamping behavior + assert MathExtension.clamp(5, 0, 10) == 5.0 + assert MathExtension.clamp(-5, 0, 10) == 0.0 + assert MathExtension.clamp(15, 0, 10) == 10.0 + assert MathExtension.clamp(0, 0, 10) == 0.0 + assert MathExtension.clamp(10, 0, 10) == 10.0 + + # Test with float values + assert MathExtension.clamp(5.5, 0, 10) == 5.5 + assert MathExtension.clamp(5.5, 0.5, 10.5) == 5.5 + assert MathExtension.clamp(0.25, 0.5, 10.5) == 0.5 + assert MathExtension.clamp(11.0, 0.5, 10.5) == 10.5 + + # Test with negative ranges + assert MathExtension.clamp(-5, -10, -1) == -5.0 + assert MathExtension.clamp(-15, -10, -1) == -10.0 + assert MathExtension.clamp(0, -10, -1) == -1.0 + + # Test with non-range + assert MathExtension.clamp(5, 10, 10) == 10.0 + + # Test error handling - invalid input types + for case in ( + "{{ clamp('invalid', 0, 10) }}", + "{{ clamp(5, 'invalid', 10) }}", + "{{ clamp(5, 0, 'invalid') }}", + ): + with pytest.raises(TemplateError): + render(hass, case) + + +def test_wrap(hass: HomeAssistant) -> None: + """Test wrap function.""" + # Test function and filter usage in templates. + assert render(hass, "{{ wrap(15, 0, 10) }}") == 5.0 + assert render(hass, "{{ -5 | wrap(0, 10) }}") == 5.0 + + # Test basic wrapping behavior + assert MathExtension.wrap(5, 0, 10) == 5.0 + assert MathExtension.wrap(10, 0, 10) == 0.0 # max wraps to min + assert MathExtension.wrap(15, 0, 10) == 5.0 + assert MathExtension.wrap(25, 0, 10) == 5.0 + assert MathExtension.wrap(-5, 0, 10) == 5.0 + assert MathExtension.wrap(-10, 0, 10) == 0.0 + + # Test angle wrapping (common use case) + assert MathExtension.wrap(370, 0, 360) == 10.0 + assert MathExtension.wrap(-10, 0, 360) == 350.0 + assert MathExtension.wrap(720, 0, 360) == 0.0 + assert MathExtension.wrap(361, 0, 360) == 1.0 + + # Test with float values + assert MathExtension.wrap(10.5, 0, 10) == 0.5 + assert MathExtension.wrap(370.5, 0, 360) == 10.5 + + # Test with negative ranges + assert MathExtension.wrap(-15, -10, 0) == -5.0 + assert MathExtension.wrap(5, -10, 0) == -5.0 + + # Test with arbitrary ranges + assert MathExtension.wrap(25, 10, 20) == 15.0 + assert MathExtension.wrap(5, 10, 20) == 15.0 + + # Test with non-range + assert MathExtension.wrap(5, 10, 10) == 10.0 + + # Test error handling - invalid input types + for case in ( + "{{ wrap('invalid', 0, 10) }}", + "{{ wrap(5, 'invalid', 10) }}", + "{{ wrap(5, 0, 'invalid') }}", + ): + with pytest.raises(TemplateError): + render(hass, case) + + +def test_remap(hass: HomeAssistant) -> None: + """Test remap function.""" + # Test function and filter usage in templates, with kitchen sink parameters. + # We don't check the return value; that's covered by the unit tests below. + assert render(hass, "{{ remap(5, 0, 6, 0, 740, steps=10) }}") + assert render(hass, "{{ 50 | remap(0, 100, 0, 10, steps=8) }}") + + # Test basic remapping - scale from 0-10 to 0-100 + assert MathExtension.remap(0, 0, 10, 0, 100) == 0.0 + assert MathExtension.remap(5, 0, 10, 0, 100) == 50.0 + assert MathExtension.remap(10, 0, 10, 0, 100) == 100.0 + + # Test with different input and output ranges + assert MathExtension.remap(50, 0, 100, 0, 10) == 5.0 + assert MathExtension.remap(25, 0, 100, 0, 10) == 2.5 + + # Test with negative ranges + assert MathExtension.remap(0, -10, 10, 0, 100) == 50.0 + assert MathExtension.remap(-10, -10, 10, 0, 100) == 0.0 + assert MathExtension.remap(10, -10, 10, 0, 100) == 100.0 + + # Test inverted output range + assert MathExtension.remap(0, 0, 10, 100, 0) == 100.0 + assert MathExtension.remap(5, 0, 10, 100, 0) == 50.0 + assert MathExtension.remap(10, 0, 10, 100, 0) == 0.0 + + # Test values outside input range, and edge modes + assert MathExtension.remap(15, 0, 10, 0, 100, edges="none") == 150.0 + assert MathExtension.remap(-4, 0, 10, 0, 100, edges="none") == -40.0 + assert MathExtension.remap(15, 0, 10, 0, 80, edges="clamp") == 80.0 + assert MathExtension.remap(-5, 0, 10, -1, 1, edges="clamp") == -1 + assert MathExtension.remap(15, 0, 10, 0, 100, edges="wrap") == 50.0 + assert MathExtension.remap(-5, 0, 10, 0, 100, edges="wrap") == 50.0 + + # Test sensor conversion use case: Celsius to Fahrenheit: 0-100°C to 32-212°F + assert MathExtension.remap(0, 0, 100, 32, 212) == 32.0 + assert MathExtension.remap(100, 0, 100, 32, 212) == 212.0 + assert MathExtension.remap(50, 0, 100, 32, 212) == 122.0 + + # Test time conversion use case: 0-60 minutes to 0-360 degrees, with wrap + assert MathExtension.remap(80, 0, 60, 0, 360, edges="wrap") == 120.0 + + # Test percentage to byte conversion (0-100% to 0-255) + assert MathExtension.remap(0, 0, 100, 0, 255) == 0.0 + assert MathExtension.remap(50, 0, 100, 0, 255) == 127.5 + assert MathExtension.remap(100, 0, 100, 0, 255) == 255.0 + + # Test with float precision + assert MathExtension.remap(2.5, 0, 10, 0, 100) == 25.0 + assert MathExtension.remap(7.5, 0, 10, 0, 100) == 75.0 + + # Test error handling + for case in ( + "{{ remap(5, 10, 10, 0, 100) }}", + "{{ remap('invalid', 0, 10, 0, 100) }}", + "{{ remap(5, 'invalid', 10, 0, 100) }}", + "{{ remap(5, 0, 'invalid', 0, 100) }}", + "{{ remap(5, 0, 10, 'invalid', 100) }}", + "{{ remap(5, 0, 10, 0, 'invalid') }}", + ): + with pytest.raises(TemplateError): + render(hass, case) + + +def test_remap_with_steps(hass: HomeAssistant) -> None: + """Test remap function with steps parameter.""" + # Test basic stepping - quantize to 10 steps + assert MathExtension.remap(0.2, 0, 10, 0, 100, steps=10) == 0.0 + assert MathExtension.remap(5.3, 0, 10, 0, 100, steps=10) == 50.0 + assert MathExtension.remap(10, 0, 10, 0, 100, steps=10) == 100.0 + + # Test stepping with intermediate values - should snap to nearest step + # With 10 steps, normalized values are rounded: 0.0, 0.1, 0.2, ..., 1.0 + assert MathExtension.remap(2.4, 0, 10, 0, 100, steps=10) == 20.0 + assert MathExtension.remap(2.5, 0, 10, 0, 100, steps=10) == 20.0 + assert MathExtension.remap(2.6, 0, 10, 0, 100, steps=10) == 30.0 + + # Test with 4 steps (0%, 25%, 50%, 75%, 100%) + assert MathExtension.remap(0, 0, 10, 0, 100, steps=4) == 0.0 + assert MathExtension.remap(2.5, 0, 10, 0, 100, steps=4) == 25.0 + assert MathExtension.remap(5, 0, 10, 0, 100, steps=4) == 50.0 + assert MathExtension.remap(7.5, 0, 10, 0, 100, steps=4) == 75.0 + assert MathExtension.remap(10, 0, 10, 0, 100, steps=4) == 100.0 + + # Test with 2 steps (0%, 50%, 100%) + assert MathExtension.remap(2, 0, 10, 0, 100, steps=2) == 0.0 + assert MathExtension.remap(6, 0, 10, 0, 100, steps=2) == 50.0 + assert MathExtension.remap(8, 0, 10, 0, 100, steps=2) == 100.0 + + # Test with 1 step (0%, 100%) + assert MathExtension.remap(0, 0, 10, 0, 100, steps=1) == 0.0 + assert MathExtension.remap(5, 0, 10, 0, 100, steps=1) == 0.0 + assert MathExtension.remap(6, 0, 10, 0, 100, steps=1) == 100.0 + assert MathExtension.remap(10, 0, 10, 0, 100, steps=1) == 100.0 + + # Test with inverted output range and steps + assert MathExtension.remap(4.8, 0, 10, 100, 0, steps=4) == 50.0 + + # Test with 0 or negative steps (should be ignored/no quantization) + assert MathExtension.remap(5, 0, 10, 0, 100, steps=0) == 50.0 + assert MathExtension.remap(2.7, 0, 10, 0, 100, steps=0) == 27.0 + assert MathExtension.remap(5, 0, 10, 0, 100, steps=-1) == 50.0 + + +def test_remap_with_mirror(hass: HomeAssistant) -> None: + """Test the mirror edge mode of the remap function.""" + + assert [ + MathExtension.remap(i, 0, 4, 0, 1, edges="mirror") for i in range(-4, 9) + ] == [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] + + # Test with different output range + assert MathExtension.remap(15, 0, 10, 50, 150, edges="mirror") == 100.0 + assert MathExtension.remap(25, 0, 10, 50, 150, edges="mirror") == 100.0 + # Test with inverted output range + assert MathExtension.remap(15, 0, 10, 100, 0, edges="mirror") == 50.0 + assert MathExtension.remap(12, 0, 10, 100, 0, edges="mirror") == 20.0 + # Test without remapping + assert MathExtension.remap(-0.1, 0, 1, 0, 1, edges="mirror") == pytest.approx(0.1)