From 3a25bd260e32dce4b0eeb61c0b60b9253369c0dd Mon Sep 17 00:00:00 2001 From: Julian Pawlowski Date: Mon, 6 Apr 2026 12:18:48 +0000 Subject: [PATCH] feat(binary_sensor): expose period calculation diagnostics as attributes Add calculation summary attributes to best_price_period and peak_price_period binary sensors for diagnostic transparency. New attributes (all excluded from recorder history): - min_periods_configured: User's configured target per day (always shown) - periods_found_total: Actual periods found across all days (always shown) - flat_days_detected: Days where CV <= 10% reduced target to 1 (only when > 0) - relaxation_incomplete: Some days couldn't reach the target (only when true) These attributes explain observed behavior without requiring users to read logs: seeing "flat_days_detected: 1" alongside "min_periods_configured: 2, periods_found_total: 1" immediately explains why the count is lower than configured on uniform-price days. Implementation: - _compute_day_effective_min() now returns (dict, int) tuple to propagate the flat day count through to the metadata dict - flat_days_detected added to metadata["relaxation"] in calculate_periods_with_relaxation() - build_final_attributes_simple() gains optional period_metadata parameter - New add_calculation_summary_attributes() function handles the rendering - Attributes are shown even when no period is currently active Updated recorder-optimization.md: attribute counts + clarified that timestamp is correctly excluded (entity native_value is recorded separately by HA as the entity state itself). Impact: Users can understand why they received fewer periods than configured without enabling debug logging. --- .../tibber_prices/binary_sensor/attributes.py | 64 +++++++++++++++++-- .../tibber_prices/binary_sensor/core.py | 7 +- docs/developer/docs/recorder-optimization.md | 24 +++---- 3 files changed, 75 insertions(+), 20 deletions(-) diff --git a/custom_components/tibber_prices/binary_sensor/attributes.py b/custom_components/tibber_prices/binary_sensor/attributes.py index cd8eb8d..5b58c51 100644 --- a/custom_components/tibber_prices/binary_sensor/attributes.py +++ b/custom_components/tibber_prices/binary_sensor/attributes.py @@ -138,8 +138,13 @@ def get_price_intervals_attributes( current_period = period break + # Extract calculation metadata for diagnostic attributes + period_metadata = period_data.get("metadata", {}) + # Build final attributes (use filtered_periods for display) - return build_final_attributes_simple(current_period, filtered_periods, time=time, config_entry=config_entry) + return build_final_attributes_simple( + current_period, filtered_periods, time=time, config_entry=config_entry, period_metadata=period_metadata + ) def build_no_periods_result(*, time: TibberPricesTimeService) -> dict: @@ -269,6 +274,39 @@ def add_relaxation_attributes(attributes: dict, current_period: dict) -> None: attributes["relaxation_threshold_applied_%"] = current_period["relaxation_threshold_applied_%"] +def add_calculation_summary_attributes(attributes: dict, period_metadata: dict) -> None: + """ + Add calculation summary attributes (priority 7). + + Provides diagnostic visibility into the period calculation: how many periods + were requested vs. found, whether any flat days triggered adaptive min_periods, + and whether relaxation could not satisfy all days. + + Only adds non-default/interesting values to avoid clutter: + - min_periods_configured: always added (useful reference for automations) + - periods_found_total: always added + - flat_days_detected: only when > 0 (explains why fewer periods than configured) + - relaxation_incomplete: only when True (diagnostic flag for troubleshooting) + + """ + relaxation_meta = period_metadata.get("relaxation", {}) + if not relaxation_meta: + return + + if "min_periods_requested" in relaxation_meta: + attributes["min_periods_configured"] = relaxation_meta["min_periods_requested"] + + if "periods_found" in relaxation_meta: + attributes["periods_found_total"] = relaxation_meta["periods_found"] + + flat_days = relaxation_meta.get("flat_days_detected", 0) + if flat_days > 0: + attributes["flat_days_detected"] = flat_days + + if relaxation_meta.get("relaxation_incomplete"): + attributes["relaxation_incomplete"] = True + + def _convert_periods_to_display_units(period_summaries: list[dict], factor: int) -> list[dict]: """ Convert price values in periods array to display units. @@ -314,6 +352,7 @@ def build_final_attributes_simple( *, time: TibberPricesTimeService, config_entry: TibberPricesConfigEntry, + period_metadata: dict | None = None, ) -> dict: """ Build the final attributes dictionary from coordinator's period summaries. @@ -331,14 +370,17 @@ def build_final_attributes_simple( 4. Price differences (period_price_diff_from_daily_min, period_price_diff_from_daily_min_%) 5. Detail information (period_interval_count, period_position, periods_total, periods_remaining) 6. Relaxation information (relaxation_active, relaxation_level, relaxation_threshold_original_%, - relaxation_threshold_applied_%) - only if period was relaxed - 7. Meta information (periods list) + relaxation_threshold_applied_%) - only if current period was relaxed + 7. Calculation summary (min_periods_configured, periods_found_total, flat_days_detected, + relaxation_incomplete) - diagnostic info about the overall calculation + 8. Meta information (periods list) Args: current_period: The current or next period (already complete from coordinator) period_summaries: All period summaries from coordinator time: TibberPricesTimeService instance (required) config_entry: Config entry for display unit configuration + period_metadata: Metadata from coordinator's period calculation (relaxation diagnostics) Returns: Complete attributes dict with all fields @@ -370,19 +412,27 @@ def build_final_attributes_simple( # 5. Detail information add_detail_attributes(attributes, current_period) - # 6. Relaxation information (only if period was relaxed) + # 6. Relaxation information (only if current period was relaxed) add_relaxation_attributes(attributes, current_period) - # 7. Meta information (periods array - prices converted to display units) + # 7. Calculation summary (diagnostic: min_periods_configured, periods_found_total, etc.) + if period_metadata: + add_calculation_summary_attributes(attributes, period_metadata) + + # 8. Meta information (periods array - prices converted to display units) attributes["periods"] = _convert_periods_to_display_units(period_summaries, factor) return attributes # No current/next period found - return all periods with timestamp (prices converted) - return { + # Still add calculation summary so diagnostics are accessible even between periods + result: dict = { "timestamp": timestamp, - "periods": _convert_periods_to_display_units(period_summaries, factor), } + if period_metadata: + add_calculation_summary_attributes(result, period_metadata) + result["periods"] = _convert_periods_to_display_units(period_summaries, factor) + return result async def build_async_extra_state_attributes( # noqa: PLR0913 diff --git a/custom_components/tibber_prices/binary_sensor/core.py b/custom_components/tibber_prices/binary_sensor/core.py index 3d9f345..d50adb0 100644 --- a/custom_components/tibber_prices/binary_sensor/core.py +++ b/custom_components/tibber_prices/binary_sensor/core.py @@ -54,10 +54,15 @@ class TibberPricesBinarySensor(TibberPricesEntity, BinarySensorEntity, RestoreEn "rating_value", "level_id", "rating_id", - # Relaxation Details + # Relaxation Details (per-period) "relaxation_level", "relaxation_threshold_original_%", "relaxation_threshold_applied_%", + # Calculation Summary (diagnostic, changes daily → not useful in history) + "min_periods_configured", + "periods_found_total", + "flat_days_detected", + "relaxation_incomplete", # Redundant/Derived "price_spread", "volatility", diff --git a/docs/developer/docs/recorder-optimization.md b/docs/developer/docs/recorder-optimization.md index a40d78f..3579674 100644 --- a/docs/developer/docs/recorder-optimization.md +++ b/docs/developer/docs/recorder-optimization.md @@ -18,7 +18,7 @@ Both `TibberPricesSensor` and `TibberPricesBinarySensor` implement `_unrecorded_ ```python class TibberPricesSensor(TibberPricesEntity, SensorEntity): """tibber_prices Sensor class.""" - + _unrecorded_attributes = frozenset( { "description", @@ -111,13 +111,14 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity): ### 5. Temporary/Time-Bound Data -**Attributes:** `next_api_poll`, `next_midnight_turnover`, `last_api_fetch`, `last_cache_update`, `last_turnover`, `last_error`, `error` +**Attributes:** `timestamp`, `next_api_poll`, `next_midnight_turnover`, `last_api_fetch`, `last_cache_update`, `last_turnover`, `last_error`, `error` **Reason:** -- Only relevant at moment of reading -- Won't be valid after some time +- `timestamp` is the rounded-quarter reference time used at the moment of the state write — it's stale as soon as the next update fires and has no analytical value in history +- `next_api_poll`, `next_midnight_turnover` etc. are only relevant at the moment of reading; they're superseded by the next update - Similar to `entity_picture` in HA core image entities -- Superseded by next update + +**Note:** The entity's `native_value` (the actual price/state) is always recorded by HA as the entity state itself — independently of `_unrecorded_attributes`. So excluding `timestamp` does not create a gap in the time-series; the state row already carries the recording timestamp. **Impact:** ~200-400 bytes saved per state change @@ -152,8 +153,7 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity): These attributes **remain in history** because they provide essential analytical value: ### Time-Series Core -- `timestamp` - Critical for time-series analysis (ALWAYS FIRST) -- All price values - Core sensor states +- All price values - Core sensor states (the entity's `native_value` is always recorded separately) ### Diagnostics & Tracking - `cache_age_minutes` - Numeric value for diagnostics tracking over time @@ -208,11 +208,11 @@ For a typical installation with: - **Sensor Platform**: `custom_components/tibber_prices/sensor/core.py` - Class: `TibberPricesSensor` - - 47 attributes excluded + - 46 attributes excluded - **Binary Sensor Platform**: `custom_components/tibber_prices/binary_sensor/core.py` - Class: `TibberPricesBinarySensor` - - 30 attributes excluded + - 29 attributes excluded ## When to Update _unrecorded_attributes @@ -266,12 +266,12 @@ After modifying `_unrecorded_attributes`: **SQL Query to check attribute presence:** ```sql -SELECT +SELECT state_id, attributes -FROM states +FROM states WHERE entity_id = 'sensor.tibber_home_current_interval_price' -ORDER BY last_updated DESC +ORDER BY last_updated DESC LIMIT 5; ```