fix(sensor): best price calculation on v-shaped days

This commit is contained in:
Julian Pawlowski 2026-04-06 11:13:09 +00:00
parent 4d822030f9
commit 6aa76affea
10 changed files with 36 additions and 26 deletions

View file

@ -445,7 +445,7 @@ async def build_async_extra_state_attributes( # noqa: PLR0913
position="end", position="end",
) )
return attributes if attributes else None return attributes or None
def build_sync_extra_state_attributes( # noqa: PLR0913 def build_sync_extra_state_attributes( # noqa: PLR0913
@ -508,4 +508,4 @@ def build_sync_extra_state_attributes( # noqa: PLR0913
position="end", position="end",
) )
return attributes if attributes else None return attributes or None

View file

@ -133,20 +133,30 @@ def check_interval_criteria(
# - Peak price (reverse_sort=True): daily MAXIMUM # - Peak price (reverse_sort=True): daily MAXIMUM
# - Best price (reverse_sort=False): daily MINIMUM # - Best price (reverse_sort=False): daily MINIMUM
# #
# Flex band calculation (using absolute values): # Flex base = max(price_span, abs(ref_price)):
# - Peak price: [max - max*flex, max] → accept prices near the maximum # - On V-shape days (tiny minimum, large span): span wins → meaningful flex band
# - Best price: [min, min + min*flex] → accept prices near the minimum # - On flat days (large minimum, small span): ref_price wins → same as before
# #
# Examples with flex=20%: # WHY NOT plain ref_price * flex: When daily_min is a single low outlier
# - Peak: max=30 ct → accept [24, 30] ct (prices ≥ 24 ct) # (e.g., min=1 ct, avg=19 ct), the flex band collapses to near-zero
# - Best: min=10 ct → accept [10, 12] ct (prices ≤ 12 ct) # (1 ct * 15% = 0.15 ct) and no period of sufficient length can be found.
#
# WHY NOT plain span * flex: On flat days (e.g., min=30 ct, span=3 ct),
# this makes the band much narrower than before, breaking existing behaviour.
#
# Examples with flex=15%:
# - V-shape: min=1 ct, avg=19 ct → span=18 ct → flex_base=18 → threshold=1+2.7=3.7 ct (spans fixed)
# - Flat: min=30 ct, avg=33 ct → span=3 ct → flex_base=30 → threshold=30+4.5=34.5 ct (unchanged)
# - Normal: min=10 ct, avg=20 ct → span=10 ct → flex_base=10 → threshold=10+1.5=11.5 ct (unchanged)
if criteria.ref_price == 0: price_span = abs(criteria.avg_price - criteria.ref_price)
# Zero reference: flex has no effect, use strict equality flex_base = max(price_span, abs(criteria.ref_price))
if flex_base == 0:
# Degenerate case: all prices are zero → only exact zero qualifies
in_flex = price == 0 in_flex = price == 0
else: else:
# Calculate flex amount using absolute value flex_amount = flex_base * flex_abs
flex_amount = abs(criteria.ref_price) * flex_abs
if criteria.reverse_sort: if criteria.reverse_sort:
# Peak price: accept prices >= (ref_price - flex_amount) # Peak price: accept prices >= (ref_price - flex_amount)

View file

@ -40,7 +40,7 @@ class TibberPricesEntity(CoordinatorEntity[TibberPricesDataUpdateCoordinator]):
name=home_name, name=home_name,
manufacturer="Tibber", manufacturer="Tibber",
model=translated_model, model=translated_model,
serial_number=home_id if home_id else None, serial_number=home_id or None,
configuration_url="https://developer.tibber.com/explorer", configuration_url="https://developer.tibber.com/explorer",
) )

View file

@ -95,7 +95,7 @@ def add_description_attributes( # noqa: PLR0913, PLR0912
get_entity_description, get_entity_description,
) )
language = hass.config.language if hass.config.language else "en" language = hass.config.language or "en"
# Build description dict # Build description dict
desc_attrs: dict[str, str] = {} desc_attrs: dict[str, str] = {}
@ -186,7 +186,7 @@ async def async_add_description_attributes( # noqa: PLR0913, PLR0912
async_get_entity_description, async_get_entity_description,
) )
language = hass.config.language if hass.config.language else "en" language = hass.config.language or "en"
# Build description dict # Build description dict
desc_attrs: dict[str, str] = {} desc_attrs: dict[str, str] = {}

View file

@ -97,7 +97,7 @@ class TibberPricesConfigNumber(RestoreNumber, NumberEntity):
name=home_name, name=home_name,
manufacturer="Tibber", manufacturer="Tibber",
model=translated_model, model=translated_model,
serial_number=home_id if home_id else None, serial_number=home_id or None,
configuration_url="https://developer.tibber.com/explorer", configuration_url="https://developer.tibber.com/explorer",
) )
@ -233,7 +233,7 @@ class TibberPricesConfigNumber(RestoreNumber, NumberEntity):
if description: if description:
attrs["description"] = description attrs["description"] = description
return attrs if attrs else None return attrs or None
@callback @callback
def async_registry_entry_updated(self) -> None: def async_registry_entry_updated(self) -> None:

View file

@ -214,7 +214,7 @@ def build_sensor_attributes(
) )
return None return None
else: else:
return attributes if attributes else None return attributes or None
def build_extra_state_attributes( # noqa: PLR0913 def build_extra_state_attributes( # noqa: PLR0913
@ -287,7 +287,7 @@ def build_extra_state_attributes( # noqa: PLR0913
if sensor_attrs: if sensor_attrs:
attributes.update({k: v for k, v in sensor_attrs.items() if k not in ("timestamp", "error")}) attributes.update({k: v for k, v in sensor_attrs.items() if k not in ("timestamp", "error")})
return attributes if attributes else None return attributes or None
# For all other sensors: standard behavior # For all other sensors: standard behavior
# Start with default timestamp (datetime object - HA serializes automatically) # Start with default timestamp (datetime object - HA serializes automatically)
@ -322,4 +322,4 @@ def build_extra_state_attributes( # noqa: PLR0913
position="end", position="end",
) )
return attributes if attributes else None return attributes or None

View file

@ -43,7 +43,7 @@ def add_next_avg_attributes( # noqa: PLR0913
""" """
# Extract hours from sensor key (e.g., "next_avg_3h" -> 3) # Extract hours from sensor key (e.g., "next_avg_3h" -> 3)
try: try:
hours = int(key.split("_")[-1].replace("h", "")) hours = int(key.rsplit("_", maxsplit=1)[-1].replace("h", ""))
except (ValueError, AttributeError): except (ValueError, AttributeError):
return return

View file

@ -104,9 +104,9 @@ class TibberPricesTimingCalculator(TibberPricesBaseCalculator):
), ),
"period_duration": lambda: self._calc_period_duration(current_period, next_period), "period_duration": lambda: self._calc_period_duration(current_period, next_period),
"next_start_time": lambda: next_period.get("start") if next_period else None, "next_start_time": lambda: next_period.get("start") if next_period else None,
"remaining_minutes": lambda: (self._calc_remaining_minutes(current_period) if current_period else 0), "remaining_minutes": lambda: self._calc_remaining_minutes(current_period) if current_period else 0,
"progress": lambda: self._calc_progress_with_grace_period(current_period, previous_period, now), "progress": lambda: self._calc_progress_with_grace_period(current_period, previous_period, now),
"next_in_minutes": lambda: (self._calc_next_in_minutes(next_period) if next_period else None), "next_in_minutes": lambda: self._calc_next_in_minutes(next_period) if next_period else None,
} }
calculator = calculators.get(value_type) calculator = calculators.get(value_type)

View file

@ -616,7 +616,7 @@ async def handle_chartdata(call: ServiceCall) -> dict[str, Any]: # noqa: PLR091
# Mode 'segments': Add NULL points at segment boundaries for clean gaps # Mode 'segments': Add NULL points at segment boundaries for clean gaps
# Process ALL intervals as one continuous list - no special midnight handling needed # Process ALL intervals as one continuous list - no special midnight handling needed
filter_field = "rating_level" if rating_level_filter else "level" filter_field = "rating_level" if rating_level_filter else "level"
filter_values = rating_level_filter if rating_level_filter else level_filter filter_values = rating_level_filter or level_filter
use_rating = rating_level_filter is not None use_rating = rating_level_filter is not None
for i in range(len(all_prices) - 1): for i in range(len(all_prices) - 1):

View file

@ -93,7 +93,7 @@ class TibberPricesConfigSwitch(RestoreEntity, SwitchEntity):
name=home_name, name=home_name,
manufacturer="Tibber", manufacturer="Tibber",
model=translated_model, model=translated_model,
serial_number=home_id if home_id else None, serial_number=home_id or None,
configuration_url="https://developer.tibber.com/explorer", configuration_url="https://developer.tibber.com/explorer",
) )
@ -236,7 +236,7 @@ class TibberPricesConfigSwitch(RestoreEntity, SwitchEntity):
if description: if description:
attrs["description"] = description attrs["description"] = description
return attrs if attrs else None return attrs or None
@callback @callback
def async_registry_entry_updated(self) -> None: def async_registry_entry_updated(self) -> None: