Add clamp/wrap/remap to template math functions (#154537)

This commit is contained in:
Aarni Koskela
2025-11-26 12:00:12 +02:00
committed by GitHub
parent a2ade413c2
commit 655a63c104
2 changed files with 320 additions and 1 deletions

View File

@@ -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))

View File

@@ -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)