From b66cde0270b37d0716ae7442f0aa3dc6a4540224 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 12 Oct 2026 13:39:38 -1000 Subject: [PATCH] [sensor] Share calibrate_linear and calibrate_polynomial tables in flash (#20074) --- esphome/components/sensor/__init__.py | 15 ++++-- esphome/components/sensor/filter.cpp | 18 ++++--- esphome/components/sensor/filter.h | 34 ++++--------- .../sensor/test_calibrate.yaml | 51 +++++++++++++++++++ tests/component_tests/sensor/test_sensor.py | 27 ++++++++++ 5 files changed, 110 insertions(+), 35 deletions(-) create mode 100644 tests/component_tests/sensor/test_calibrate.yaml diff --git a/esphome/components/sensor/__init__.py b/esphome/components/sensor/__init__.py index 1440e07f763..8f345e3e668 100644 --- a/esphome/components/sensor/__init__.py +++ b/esphome/components/sensor/__init__.py @@ -806,6 +806,9 @@ def validate_calibrate_linear(config): return config +LINEAR_SEGMENT = cg.std_ns.class_("array").template(cg.float_, 3) + + @FILTER_REGISTRY.register( "calibrate_linear", CalibrateLinearFilter, @@ -832,9 +835,11 @@ async def calibrate_linear_filter_to_code(config, filter_id): linear_functions = [[k, b, float("NaN")]] elif config[CONF_METHOD] == "exact": linear_functions = map_linear(x, y) - return cg.new_Pvariable( - filter_id, cg.TemplateArguments(len(linear_functions)), linear_functions + # The last segment always has a NaN boundary, so the filter needs no count. + table = cg.shared_progmem_array( + "sensor_calibrate_linear", LINEAR_SEGMENT, linear_functions ) + return cg.new_Pvariable(filter_id, table) CONF_DEGREE = "degree" @@ -872,7 +877,11 @@ async def calibrate_polynomial_filter_to_code(config, filter_id): # Column vector b = [[v] for v in y] res = [v[0] for v in _lstsq(a, b)] - return cg.new_Pvariable(filter_id, cg.TemplateArguments(len(res)), res) + # The first entry holds the coefficient count, so the filter needs no count field. + table = cg.shared_progmem_array( + "sensor_calibrate_polynomial", cg.float_, [float(len(res)), *res] + ) + return cg.new_Pvariable(filter_id, table) def validate_clamp(config): diff --git a/esphome/components/sensor/filter.cpp b/esphome/components/sensor/filter.cpp index 732db5b0b4f..ed48ddebdbc 100644 --- a/esphome/components/sensor/filter.cpp +++ b/esphome/components/sensor/filter.cpp @@ -415,19 +415,21 @@ void HeartbeatFilter::initialize(Sensor *parent, Filter *next) { }); } -optional calibrate_linear_compute(const std::array *functions, size_t count, float value) { - for (size_t i = 0; i < count; i++) { - if (!std::isfinite(functions[i][2]) || value < functions[i][2]) - return (value * functions[i][0]) + functions[i][1]; +// The calibration tables hold only floats, so aligned 32-bit loads from PROGMEM are safe on ESP8266. +optional CalibrateLinearFilter::new_value(float value) { + // Always returns: codegen ends the table with a segment whose boundary is NaN. + for (const auto *function = this->functions_;; function++) { + if (!std::isfinite((*function)[2]) || value < (*function)[2]) + return (value * (*function)[0]) + (*function)[1]; } - return NAN; } -optional calibrate_polynomial_compute(const float *coefficients, size_t count, float value) { +optional CalibratePolynomialFilter::new_value(float value) { float res = 0.0f; float x = 1.0f; - for (size_t i = 0; i < count; i++) { - res += x * coefficients[i]; + const auto count = static_cast(this->coefficients_[0]); + for (size_t i = 1; i <= count; i++) { + res += x * this->coefficients_[i]; x *= value; } return res; diff --git a/esphome/components/sensor/filter.h b/esphome/components/sensor/filter.h index 01949f45df2..9b5ef493081 100644 --- a/esphome/components/sensor/filter.h +++ b/esphome/components/sensor/filter.h @@ -579,38 +579,24 @@ template class OrFilter : public Filter { bool has_value_{false}; }; -/// Non-template helper for linear calibration (implementation in filter.cpp) -optional calibrate_linear_compute(const std::array *functions, size_t count, float value); - -/// N is set by code generation to match the exact number of calibration segments. -template class CalibrateLinearFilter : public Filter { +/// Piecewise linear calibration; the segments are a PROGMEM table shared by filters with the same data. +class CalibrateLinearFilter : public Filter { public: - explicit CalibrateLinearFilter(std::initializer_list> linear_functions) { - init_array_from(this->linear_functions_, linear_functions); - } - optional new_value(float value) override { - return calibrate_linear_compute(this->linear_functions_.data(), N, value); - } + explicit CalibrateLinearFilter(const std::array *functions) : functions_(functions) {} + optional new_value(float value) override; protected: - std::array, N> linear_functions_{}; + const std::array *functions_; // Last segment has a NaN boundary, which ends the table }; -/// Non-template helper for polynomial calibration (implementation in filter.cpp) -optional calibrate_polynomial_compute(const float *coefficients, size_t count, float value); - -/// N is set by code generation to match the exact number of polynomial coefficients. -template class CalibratePolynomialFilter : public Filter { +/// Polynomial calibration; the coefficients are a PROGMEM table shared by filters with the same data. +class CalibratePolynomialFilter : public Filter { public: - explicit CalibratePolynomialFilter(std::initializer_list coefficients) { - init_array_from(this->coefficients_, coefficients); - } - optional new_value(float value) override { - return calibrate_polynomial_compute(this->coefficients_.data(), N, value); - } + explicit CalibratePolynomialFilter(const float *coefficients) : coefficients_(coefficients) {} + optional new_value(float value) override; protected: - std::array coefficients_{}; + const float *coefficients_; // [count, c0, c1, ...] }; class ClampFilter : public Filter { diff --git a/tests/component_tests/sensor/test_calibrate.yaml b/tests/component_tests/sensor/test_calibrate.yaml new file mode 100644 index 00000000000..41a87c87795 --- /dev/null +++ b/tests/component_tests/sensor/test_calibrate.yaml @@ -0,0 +1,51 @@ +--- +esphome: + name: test + +esp8266: + board: d1_mini_lite + +sensor: + - platform: template + id: exact_a + lambda: return 1.0; + filters: + - calibrate_linear: + method: exact + datapoints: + - 0.0 -> 0.0 + - 10.0 -> 100.0 + - 20.0 -> 150.0 + - platform: template + id: exact_b + lambda: return 1.0; + filters: + - calibrate_linear: + method: exact + datapoints: + - 0.0 -> 0.0 + - 10.0 -> 100.0 + - 20.0 -> 150.0 + - platform: template + id: least_squares + lambda: return 1.0; + filters: + - calibrate_linear: + - 0.0 -> 1.0 + - 10.0 -> 21.0 + - platform: template + id: polynomial + lambda: return 1.0; + filters: + - calibrate_polynomial: + degree: 1 + datapoints: + - 0.0 -> 1.0 + - 10.0 -> 21.0 + # User ids equal to the old derived table name and to the helper's base name. + - platform: template + id: sensor_calibratelinearfilter_id_data + lambda: return 1.0; + - platform: template + id: sensor_calibrate_linear + lambda: return 1.0; diff --git a/tests/component_tests/sensor/test_sensor.py b/tests/component_tests/sensor/test_sensor.py index 9d18fa36b8f..07981c18976 100644 --- a/tests/component_tests/sensor/test_sensor.py +++ b/tests/component_tests/sensor/test_sensor.py @@ -1,5 +1,8 @@ """Tests for the sensor component.""" +from collections.abc import Callable +import re + from tests.component_tests.helpers import extract_packed_value @@ -15,3 +18,27 @@ def test_sensor_device_class_set(generate_main): # Then: device_class: voltage means packed value must be non-zero packed = extract_packed_value(main_cpp, "s_1") assert packed != 0 + + +def test_calibration_tables_are_shared_progmem( + generate_main: Callable[[str], str], +) -> None: + """Calibration data is a PROGMEM table, shared by filters with identical data.""" + main_cpp = generate_main("tests/component_tests/sensor/test_calibrate.yaml") + + linear = re.findall( + r"static constexpr std::array (\w+)\[\] PROGMEM", main_cpp + ) + assert len(linear) == 2 # the two exact filters share one table + exact, least_squares = linear + assert main_cpp.count(f"sensor::CalibrateLinearFilter({exact});") == 2 + assert f"sensor::CalibrateLinearFilter({least_squares});" in main_cpp + + poly = re.search( + r"static constexpr float (\w+)\[\] PROGMEM = \{2\.0f, 1\.0f, 2\.0f\};", main_cpp + ) + assert poly is not None + assert f"sensor::CalibratePolynomialFilter({poly.group(1)});" in main_cpp + # Table names never reuse a user id. + assert exact != "sensor_calibrate_linear" + assert "sensor_calibratelinearfilter_id_data" not in (exact, least_squares)