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.
This commit is contained in:
Julian Pawlowski 2026-04-06 12:18:48 +00:00
parent 1e1c8d5299
commit 3a25bd260e
3 changed files with 75 additions and 20 deletions

View file

@ -138,8 +138,13 @@ def get_price_intervals_attributes(
current_period = period current_period = period
break break
# Extract calculation metadata for diagnostic attributes
period_metadata = period_data.get("metadata", {})
# Build final attributes (use filtered_periods for display) # 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: 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_%"] 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]: def _convert_periods_to_display_units(period_summaries: list[dict], factor: int) -> list[dict]:
""" """
Convert price values in periods array to display units. Convert price values in periods array to display units.
@ -314,6 +352,7 @@ def build_final_attributes_simple(
*, *,
time: TibberPricesTimeService, time: TibberPricesTimeService,
config_entry: TibberPricesConfigEntry, config_entry: TibberPricesConfigEntry,
period_metadata: dict | None = None,
) -> dict: ) -> dict:
""" """
Build the final attributes dictionary from coordinator's period summaries. 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_%) 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) 5. Detail information (period_interval_count, period_position, periods_total, periods_remaining)
6. Relaxation information (relaxation_active, relaxation_level, relaxation_threshold_original_%, 6. Relaxation information (relaxation_active, relaxation_level, relaxation_threshold_original_%,
relaxation_threshold_applied_%) - only if period was relaxed relaxation_threshold_applied_%) - only if current period was relaxed
7. Meta information (periods list) 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: Args:
current_period: The current or next period (already complete from coordinator) current_period: The current or next period (already complete from coordinator)
period_summaries: All period summaries from coordinator period_summaries: All period summaries from coordinator
time: TibberPricesTimeService instance (required) time: TibberPricesTimeService instance (required)
config_entry: Config entry for display unit configuration config_entry: Config entry for display unit configuration
period_metadata: Metadata from coordinator's period calculation (relaxation diagnostics)
Returns: Returns:
Complete attributes dict with all fields Complete attributes dict with all fields
@ -370,19 +412,27 @@ def build_final_attributes_simple(
# 5. Detail information # 5. Detail information
add_detail_attributes(attributes, current_period) 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) 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) attributes["periods"] = _convert_periods_to_display_units(period_summaries, factor)
return attributes return attributes
# No current/next period found - return all periods with timestamp (prices converted) # 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, "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 async def build_async_extra_state_attributes( # noqa: PLR0913

View file

@ -54,10 +54,15 @@ class TibberPricesBinarySensor(TibberPricesEntity, BinarySensorEntity, RestoreEn
"rating_value", "rating_value",
"level_id", "level_id",
"rating_id", "rating_id",
# Relaxation Details # Relaxation Details (per-period)
"relaxation_level", "relaxation_level",
"relaxation_threshold_original_%", "relaxation_threshold_original_%",
"relaxation_threshold_applied_%", "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 # Redundant/Derived
"price_spread", "price_spread",
"volatility", "volatility",

View file

@ -111,13 +111,14 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
### 5. Temporary/Time-Bound Data ### 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:** **Reason:**
- Only relevant at moment of reading - `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
- Won't be valid after some time - `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 - 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 **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: These attributes **remain in history** because they provide essential analytical value:
### Time-Series Core ### Time-Series Core
- `timestamp` - Critical for time-series analysis (ALWAYS FIRST) - All price values - Core sensor states (the entity's `native_value` is always recorded separately)
- All price values - Core sensor states
### Diagnostics & Tracking ### Diagnostics & Tracking
- `cache_age_minutes` - Numeric value for diagnostics tracking over time - `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` - **Sensor Platform**: `custom_components/tibber_prices/sensor/core.py`
- Class: `TibberPricesSensor` - Class: `TibberPricesSensor`
- 47 attributes excluded - 46 attributes excluded
- **Binary Sensor Platform**: `custom_components/tibber_prices/binary_sensor/core.py` - **Binary Sensor Platform**: `custom_components/tibber_prices/binary_sensor/core.py`
- Class: `TibberPricesBinarySensor` - Class: `TibberPricesBinarySensor`
- 30 attributes excluded - 29 attributes excluded
## When to Update _unrecorded_attributes ## When to Update _unrecorded_attributes