mirror of
https://github.com/Electric-Special/ha-core.git
synced 2026-03-21 08:06:00 +01:00
Add clamp/wrap/remap to template math functions (#154537)
This commit is contained in:
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user