mirror of
https://github.com/jpawlowski/hass.tibber_prices.git
synced 2026-04-07 08:03:40 +00:00
fix(sensor): best price calculation on v-shaped days
This commit is contained in:
parent
4d822030f9
commit
6aa76affea
10 changed files with 36 additions and 26 deletions
|
|
@ -445,7 +445,7 @@ async def build_async_extra_state_attributes( # noqa: PLR0913
|
|||
position="end",
|
||||
)
|
||||
|
||||
return attributes if attributes else None
|
||||
return attributes or None
|
||||
|
||||
|
||||
def build_sync_extra_state_attributes( # noqa: PLR0913
|
||||
|
|
@ -508,4 +508,4 @@ def build_sync_extra_state_attributes( # noqa: PLR0913
|
|||
position="end",
|
||||
)
|
||||
|
||||
return attributes if attributes else None
|
||||
return attributes or None
|
||||
|
|
|
|||
|
|
@ -133,20 +133,30 @@ def check_interval_criteria(
|
|||
# - Peak price (reverse_sort=True): daily MAXIMUM
|
||||
# - Best price (reverse_sort=False): daily MINIMUM
|
||||
#
|
||||
# Flex band calculation (using absolute values):
|
||||
# - Peak price: [max - max*flex, max] → accept prices near the maximum
|
||||
# - Best price: [min, min + min*flex] → accept prices near the minimum
|
||||
# Flex base = max(price_span, abs(ref_price)):
|
||||
# - On V-shape days (tiny minimum, large span): span wins → meaningful flex band
|
||||
# - On flat days (large minimum, small span): ref_price wins → same as before
|
||||
#
|
||||
# Examples with flex=20%:
|
||||
# - Peak: max=30 ct → accept [24, 30] ct (prices ≥ 24 ct)
|
||||
# - Best: min=10 ct → accept [10, 12] ct (prices ≤ 12 ct)
|
||||
# WHY NOT plain ref_price * flex: When daily_min is a single low outlier
|
||||
# (e.g., min=1 ct, avg=19 ct), the flex band collapses to near-zero
|
||||
# (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:
|
||||
# Zero reference: flex has no effect, use strict equality
|
||||
price_span = abs(criteria.avg_price - criteria.ref_price)
|
||||
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
|
||||
else:
|
||||
# Calculate flex amount using absolute value
|
||||
flex_amount = abs(criteria.ref_price) * flex_abs
|
||||
flex_amount = flex_base * flex_abs
|
||||
|
||||
if criteria.reverse_sort:
|
||||
# Peak price: accept prices >= (ref_price - flex_amount)
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ class TibberPricesEntity(CoordinatorEntity[TibberPricesDataUpdateCoordinator]):
|
|||
name=home_name,
|
||||
manufacturer="Tibber",
|
||||
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",
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -95,7 +95,7 @@ def add_description_attributes( # noqa: PLR0913, PLR0912
|
|||
get_entity_description,
|
||||
)
|
||||
|
||||
language = hass.config.language if hass.config.language else "en"
|
||||
language = hass.config.language or "en"
|
||||
|
||||
# Build description dict
|
||||
desc_attrs: dict[str, str] = {}
|
||||
|
|
@ -186,7 +186,7 @@ async def async_add_description_attributes( # noqa: PLR0913, PLR0912
|
|||
async_get_entity_description,
|
||||
)
|
||||
|
||||
language = hass.config.language if hass.config.language else "en"
|
||||
language = hass.config.language or "en"
|
||||
|
||||
# Build description dict
|
||||
desc_attrs: dict[str, str] = {}
|
||||
|
|
|
|||
|
|
@ -97,7 +97,7 @@ class TibberPricesConfigNumber(RestoreNumber, NumberEntity):
|
|||
name=home_name,
|
||||
manufacturer="Tibber",
|
||||
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",
|
||||
)
|
||||
|
||||
|
|
@ -233,7 +233,7 @@ class TibberPricesConfigNumber(RestoreNumber, NumberEntity):
|
|||
if description:
|
||||
attrs["description"] = description
|
||||
|
||||
return attrs if attrs else None
|
||||
return attrs or None
|
||||
|
||||
@callback
|
||||
def async_registry_entry_updated(self) -> None:
|
||||
|
|
|
|||
|
|
@ -214,7 +214,7 @@ def build_sensor_attributes(
|
|||
)
|
||||
return None
|
||||
else:
|
||||
return attributes if attributes else None
|
||||
return attributes or None
|
||||
|
||||
|
||||
def build_extra_state_attributes( # noqa: PLR0913
|
||||
|
|
@ -287,7 +287,7 @@ def build_extra_state_attributes( # noqa: PLR0913
|
|||
if sensor_attrs:
|
||||
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
|
||||
# Start with default timestamp (datetime object - HA serializes automatically)
|
||||
|
|
@ -322,4 +322,4 @@ def build_extra_state_attributes( # noqa: PLR0913
|
|||
position="end",
|
||||
)
|
||||
|
||||
return attributes if attributes else None
|
||||
return attributes or None
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ def add_next_avg_attributes( # noqa: PLR0913
|
|||
"""
|
||||
# Extract hours from sensor key (e.g., "next_avg_3h" -> 3)
|
||||
try:
|
||||
hours = int(key.split("_")[-1].replace("h", ""))
|
||||
hours = int(key.rsplit("_", maxsplit=1)[-1].replace("h", ""))
|
||||
except (ValueError, AttributeError):
|
||||
return
|
||||
|
||||
|
|
|
|||
|
|
@ -104,9 +104,9 @@ class TibberPricesTimingCalculator(TibberPricesBaseCalculator):
|
|||
),
|
||||
"period_duration": lambda: self._calc_period_duration(current_period, next_period),
|
||||
"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),
|
||||
"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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
# Process ALL intervals as one continuous list - no special midnight handling needed
|
||||
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
|
||||
|
||||
for i in range(len(all_prices) - 1):
|
||||
|
|
|
|||
|
|
@ -93,7 +93,7 @@ class TibberPricesConfigSwitch(RestoreEntity, SwitchEntity):
|
|||
name=home_name,
|
||||
manufacturer="Tibber",
|
||||
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",
|
||||
)
|
||||
|
||||
|
|
@ -236,7 +236,7 @@ class TibberPricesConfigSwitch(RestoreEntity, SwitchEntity):
|
|||
if description:
|
||||
attrs["description"] = description
|
||||
|
||||
return attrs if attrs else None
|
||||
return attrs or None
|
||||
|
||||
@callback
|
||||
def async_registry_entry_updated(self) -> None:
|
||||
|
|
|
|||
Loading…
Reference in a new issue