From 99aa5043a3b9ac35e8cfd245330cfddfc6c601ce Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 2 Mar 2026 00:05:06 -1000 Subject: [PATCH] [core] Add unit tests for micros_to_millis, make constexpr Add Python unit tests (70 cases) verifying the Euclidean decomposition matches us // 1000 across edge cases: small values, hi/lo boundaries, carry-path overflow, realistic uptimes, and shift-boundary mod-8 variations. Make micros_to_millis constexpr to enable compile-time validation via static_assert. --- esphome/core/helpers.h | 8 +- tests/unit_tests/test_micros_to_millis.py | 128 ++++++++++++++++++++++ 2 files changed, 132 insertions(+), 4 deletions(-) create mode 100644 tests/unit_tests/test_micros_to_millis.py diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index c5f10e082b..afcb1a65d7 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -618,10 +618,10 @@ inline uint32_t fnv1a_hash(const std::string &str) { return fnv1a_hash(str.c_str /// /// See: https://en.wikipedia.org/wiki/Euclidean_division /// See: https://ridiculousfish.com/blog/posts/labor-of-division-episode-iii.html -inline ESPHOME_ALWAYS_INLINE uint32_t micros_to_millis(uint64_t us) { - static constexpr uint32_t D = 125U; - static constexpr uint32_t Q = static_cast((1ULL << 32) / D); // 34359738 - static constexpr uint32_t R = static_cast((1ULL << 32) % D); // 46 +inline constexpr ESPHOME_ALWAYS_INLINE uint32_t micros_to_millis(uint64_t us) { + constexpr uint32_t D = 125U; + constexpr uint32_t Q = static_cast((1ULL << 32) / D); // 34359738 + constexpr uint32_t R = static_cast((1ULL << 32) % D); // 46 // 1000 = 8 * 125; divide-by-8 is a free shift uint64_t x = us >> 3; uint32_t lo = static_cast(x); diff --git a/tests/unit_tests/test_micros_to_millis.py b/tests/unit_tests/test_micros_to_millis.py new file mode 100644 index 0000000000..5196185b0c --- /dev/null +++ b/tests/unit_tests/test_micros_to_millis.py @@ -0,0 +1,128 @@ +"""Tests for micros_to_millis Euclidean decomposition. + +Verifies that the Python equivalent of the C++ micros_to_millis() helper +in esphome/core/helpers.h matches the reference (us // 1000) across +edge cases and overflow boundaries. +""" + +import pytest + +# Constants matching the C++ implementation (shift3+div125 variant) +D = 125 +Q = (1 << 32) // D # 34359738 +R = (1 << 32) % D # 46 +UINT32_MAX = 0xFFFFFFFF + + +def micros_to_millis(us: int) -> int: + """Convert microseconds to milliseconds using Euclidean decomposition.""" + x = us >> 3 + lo = x & UINT32_MAX + hi = (x >> 32) & UINT32_MAX + adj = (hi * R + lo) & UINT32_MAX + if adj < lo: + return (hi * Q + (adj + R) // D + Q) & UINT32_MAX + return (hi * Q + adj // D) & UINT32_MAX + + +def reference(us: int) -> int: + """Reference implementation: truncated 32-bit result of us / 1000.""" + return (us // 1000) & UINT32_MAX + + +@pytest.mark.parametrize( + "us", + [ + 0, + 1, + 999, + 1000, + 1001, + 7999, + 8000, + 8001, + 999_999, + 1_000_000, + UINT32_MAX - 1, + UINT32_MAX, + UINT32_MAX + 1, + ], + ids=lambda v: f"us={v}", +) +def test_small_and_boundary_values(us): + assert micros_to_millis(us) == reference(us) + + +@pytest.mark.parametrize( + "hi", + [1, 2, 100, 603, 1000, 5000, 10000, 14685, 0xFFFF], + ids=lambda v: f"hi={v}", +) +@pytest.mark.parametrize( + "lo_offset", + [0, 1, 999, UINT32_MAX - 999, UINT32_MAX], + ids=lambda v: f"lo={v}", +) +def test_hi_lo_combinations(hi, lo_offset): + us = (hi << 32) | lo_offset + assert micros_to_millis(us) == reference(us) + + +@pytest.mark.parametrize( + "hi", + [1, 50, 100, 500, 1000, 5000], + ids=lambda v: f"hi={v}", +) +def test_carry_boundary(hi): + """Test around the adj overflow boundary (hi * R + lo > UINT32_MAX).""" + # After >>3, the decomposition uses R=46 + # Carry boundary for original us: compute where adj wraps + # hi_shifted = (us >> 3) >> 32 = us >> 35 + # We construct us such that hi_shifted = hi + base = hi << 35 + hi_r = hi * R + if hi_r < UINT32_MAX: + threshold_lo = UINT32_MAX - hi_r + # Test around the boundary in the shifted domain + for lo in [threshold_lo - 1, threshold_lo, threshold_lo + 1]: + us = base | (lo << 3) # Scale lo back to us domain + assert micros_to_millis(us) == reference(us) + + +@pytest.mark.parametrize( + "us", + [ + # 30-day uptime in microseconds + 2_592_000_000_000, + # 1-year uptime + 31_536_000_000_000, + # Near safe limit (~101,700 years) + 3_200_000_000_000_000_000, + ], + ids=["30_days", "1_year", "near_safe_limit"], +) +def test_realistic_uptimes(us): + assert micros_to_millis(us) == reference(us) + + +def test_shift_boundary_mod8(): + """Values where us % 8 varies — exercises the >>3 shift edge.""" + for base in [0, 1000, 8000, UINT32_MAX, 603 << 32]: + for offset in range(8): + us = base + offset + assert micros_to_millis(us) == reference(us) + + +def test_constants_match(): + """Verify the Euclidean decomposition constants are correct.""" + assert Q == 34359738 + assert R == 46 + assert Q * D + R == (1 << 32) + + +def test_constexpr_values(): + """Values suitable for static_assert in C++ if made constexpr.""" + assert micros_to_millis(0) == 0 + assert micros_to_millis(999) == 0 + assert micros_to_millis(1000) == 1 + assert micros_to_millis(2_592_000_000_000) == 2_592_000_000