[sensor] Filter to round to significant digits (#11157)

Co-authored-by: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
Co-authored-by: J. Nick Koston <nick@koston.org>
Co-authored-by: J. Nick Koston <nick+github@koston.org>
This commit is contained in:
Geoff
2026-04-21 07:00:03 -07:00
committed by GitHub
parent 0c9d443a5c
commit 43c6b839cd
6 changed files with 108 additions and 0 deletions

View File

@@ -118,6 +118,7 @@ from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor
from esphome.util import Registry
CODEOWNERS = ["@esphome/core"]
DEVICE_CLASSES = [
DEVICE_CLASS_ABSOLUTE_HUMIDITY,
DEVICE_CLASS_APPARENT_POWER,
@@ -293,6 +294,7 @@ SensorInRangeCondition = sensor_ns.class_("SensorInRangeCondition", Filter)
ClampFilter = sensor_ns.class_("ClampFilter", Filter)
RoundFilter = sensor_ns.class_("RoundFilter", Filter)
RoundMultipleFilter = sensor_ns.class_("RoundMultipleFilter", Filter)
RoundSignificantDigitsFilter = sensor_ns.class_("RoundSignificantDigitsFilter", Filter)
validate_unit_of_measurement = cv.All(
cv.string_strict,
@@ -900,6 +902,18 @@ async def round_multiple_filter_to_code(config, filter_id):
)
@FILTER_REGISTRY.register(
"round_to_significant_digits",
RoundSignificantDigitsFilter,
cv.int_range(min=1, max=6),
)
async def round_significant_digits_filter_to_code(config, filter_id):
return cg.new_Pvariable(
filter_id,
cg.TemplateArguments(config),
)
async def build_filters(config):
return await cg.build_registry_list(FILTER_REGISTRY, config)

View File

@@ -604,6 +604,19 @@ class RoundMultipleFilter : public Filter {
float multiple_;
};
template<uint8_t Digits> class RoundSignificantDigitsFilter : public Filter {
public:
optional<float> new_value(float value) override {
if (std::isfinite(value)) {
if (value == 0.0f)
return 0.0f;
float factor = pow10_int(Digits - 1 - ilog10(value));
return roundf(value * factor) / factor;
}
return value;
}
};
class ToNTCResistanceFilter : public Filter {
public:
ToNTCResistanceFilter(double a, double b, double c) : a_(a), b_(b), c_(c) {}

View File

@@ -413,6 +413,23 @@ ParseOnOffState parse_on_off(const char *str, const char *on, const char *off) {
return PARSE_NONE;
}
int8_t ilog10(float value) {
float abs_val = fabsf(value);
int8_t exp = 0;
if (abs_val >= 10.0f) {
while (abs_val >= 10.0f) {
abs_val /= 10.0f;
exp++;
}
} else if (abs_val < 1.0f) {
while (abs_val < 1.0f) {
abs_val *= 10.0f;
exp--;
}
}
return exp;
}
static inline void normalize_accuracy_decimals(float &value, int8_t &accuracy_decimals) {
if (accuracy_decimals < 0) {
float divisor;

View File

@@ -740,6 +740,11 @@ template<size_t STACK_SIZE, typename T = uint8_t> class SmallBufferWithHeapFallb
/// @name Mathematics
///@{
/// Compute floor(log10(fabs(value))) using iterative comparison.
/// Avoids pulling in __ieee754_logf/log10f (~1KB flash).
/// Only valid for finite, non-zero values.
int8_t ilog10(float value);
/// Compute 10^exp using iterative multiplication/division.
/// Avoids pulling in powf/__ieee754_powf (~2.3KB flash) for small integer exponents. // NOLINT
/// Matches powf(10, exp) for the int8_t exponent range used by sensor accuracy_decimals. // NOLINT

View File

@@ -0,0 +1,58 @@
#include <gtest/gtest.h>
#include <cmath>
#include "esphome/core/helpers.h"
namespace esphome {
TEST(HelpersTest, Ilog10PowersOfTen) {
EXPECT_EQ(ilog10(1.0f), 0);
EXPECT_EQ(ilog10(10.0f), 1);
EXPECT_EQ(ilog10(100.0f), 2);
EXPECT_EQ(ilog10(1000.0f), 3);
EXPECT_EQ(ilog10(10000.0f), 4);
EXPECT_EQ(ilog10(100000.0f), 5);
EXPECT_EQ(ilog10(0.1f), -1);
EXPECT_EQ(ilog10(0.001f), -3);
}
TEST(HelpersTest, Ilog10General) {
EXPECT_EQ(ilog10(5.0f), 0);
EXPECT_EQ(ilog10(9.99f), 0);
EXPECT_EQ(ilog10(50.0f), 1);
EXPECT_EQ(ilog10(99.0f), 1);
EXPECT_EQ(ilog10(999.0f), 2);
EXPECT_EQ(ilog10(0.5f), -1);
EXPECT_EQ(ilog10(0.0072f), -3);
EXPECT_EQ(ilog10(120000.0f), 5);
EXPECT_EQ(ilog10(123456.789f), 5);
}
TEST(HelpersTest, Ilog10Negative) {
EXPECT_EQ(ilog10(-1.0f), 0);
EXPECT_EQ(ilog10(-10.0f), 1);
EXPECT_EQ(ilog10(-0.1f), -1);
EXPECT_EQ(ilog10(-123.456f), 2);
}
// Verify that ilog10 + pow10_int produces the same rounding result as log10/pow.
// ilog10 may differ from floor(log10f()) for values not exactly representable in float
// (e.g. 0.01f is 0.00999...), but the full round-trip must match.
TEST(HelpersTest, Ilog10RoundTripMatchesLog10) {
float values[] = {0.0072f, 0.05f, 0.1f, 0.5f, 1.0f, 3.14f, 9.99f, 10.0f, 42.0f, 100.0f,
1234.5f, 9999.0f, 10000.0f, 99999.0f, 120000.0f, 999999.0f, -1.0f, -0.1f, -123.456f, -10000.0f};
for (uint8_t digits = 1; digits <= 6; digits++) {
for (float v : values) {
// New implementation using ilog10 + pow10_int
float factor_new = pow10_int(digits - 1 - ilog10(v));
float result_new = roundf(v * factor_new) / factor_new;
// Reference using log10/pow
double factor_ref = pow(10.0, digits - std::ceil(std::log10(std::fabs(v))));
float result_ref = static_cast<float>(round(v * factor_ref) / factor_ref);
EXPECT_FLOAT_EQ(result_new, result_ref) << "mismatch for value=" << v << " digits=" << (int) digits;
}
}
}
} // namespace esphome

View File

@@ -171,6 +171,7 @@ sensor:
quantile: .9
- round: 1
- round_to_multiple_of: 0.25
- round_to_significant_digits: 3
- skip_initial: 3
- sliding_window_moving_average:
window_size: 15