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",
|
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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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] = {}
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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):
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue