feat: Add price trend thresholds configuration and update related calculations

This commit is contained in:
Julian Pawlowski 2025-11-08 16:02:21 +00:00
parent 5ba0633d15
commit db0d65a939
9 changed files with 151 additions and 13 deletions

View file

@ -48,6 +48,8 @@ from .const import (
CONF_PEAK_PRICE_MIN_PERIOD_LENGTH,
CONF_PRICE_RATING_THRESHOLD_HIGH,
CONF_PRICE_RATING_THRESHOLD_LOW,
CONF_PRICE_TREND_THRESHOLD_FALLING,
CONF_PRICE_TREND_THRESHOLD_RISING,
DEFAULT_BEST_PRICE_FLEX,
DEFAULT_BEST_PRICE_MIN_DISTANCE_FROM_AVG,
DEFAULT_BEST_PRICE_MIN_PERIOD_LENGTH,
@ -57,6 +59,8 @@ from .const import (
DEFAULT_PEAK_PRICE_MIN_PERIOD_LENGTH,
DEFAULT_PRICE_RATING_THRESHOLD_HIGH,
DEFAULT_PRICE_RATING_THRESHOLD_LOW,
DEFAULT_PRICE_TREND_THRESHOLD_FALLING,
DEFAULT_PRICE_TREND_THRESHOLD_RISING,
DOMAIN,
LOGGER,
)
@ -590,7 +594,7 @@ class TibberPricesOptionsFlowHandler(OptionsFlow):
"""Configure peak price period settings."""
if user_input is not None:
self._options.update(user_input)
return self.async_create_entry(title="", data=self._options)
return await self.async_step_price_trend()
return self.async_show_form(
step_id="peak_price",
@ -648,3 +652,49 @@ class TibberPricesOptionsFlowHandler(OptionsFlow):
}
),
)
async def async_step_price_trend(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
"""Configure price trend thresholds."""
if user_input is not None:
self._options.update(user_input)
return self.async_create_entry(title="", data=self._options)
return self.async_show_form(
step_id="price_trend",
data_schema=vol.Schema(
{
vol.Optional(
CONF_PRICE_TREND_THRESHOLD_RISING,
default=int(
self.config_entry.options.get(
CONF_PRICE_TREND_THRESHOLD_RISING,
DEFAULT_PRICE_TREND_THRESHOLD_RISING,
)
),
): NumberSelector(
NumberSelectorConfig(
min=1,
max=50,
step=1,
mode=NumberSelectorMode.SLIDER,
),
),
vol.Optional(
CONF_PRICE_TREND_THRESHOLD_FALLING,
default=int(
self.config_entry.options.get(
CONF_PRICE_TREND_THRESHOLD_FALLING,
DEFAULT_PRICE_TREND_THRESHOLD_FALLING,
)
),
): NumberSelector(
NumberSelectorConfig(
min=-50,
max=-1,
step=1,
mode=NumberSelectorMode.SLIDER,
),
),
}
),
)

View file

@ -26,6 +26,8 @@ CONF_BEST_PRICE_MIN_PERIOD_LENGTH = "best_price_min_period_length"
CONF_PEAK_PRICE_MIN_PERIOD_LENGTH = "peak_price_min_period_length"
CONF_PRICE_RATING_THRESHOLD_LOW = "price_rating_threshold_low"
CONF_PRICE_RATING_THRESHOLD_HIGH = "price_rating_threshold_high"
CONF_PRICE_TREND_THRESHOLD_RISING = "price_trend_threshold_rising"
CONF_PRICE_TREND_THRESHOLD_FALLING = "price_trend_threshold_falling"
ATTRIBUTION = "Data provided by Tibber"
@ -40,6 +42,8 @@ DEFAULT_BEST_PRICE_MIN_PERIOD_LENGTH = 60 # 60 minutes minimum period length fo
DEFAULT_PEAK_PRICE_MIN_PERIOD_LENGTH = 60 # 60 minutes minimum period length for peak price (user-facing, minutes)
DEFAULT_PRICE_RATING_THRESHOLD_LOW = -10 # Default rating threshold low percentage
DEFAULT_PRICE_RATING_THRESHOLD_HIGH = 10 # Default rating threshold high percentage
DEFAULT_PRICE_TREND_THRESHOLD_RISING = 5 # Default trend threshold for rising prices (%)
DEFAULT_PRICE_TREND_THRESHOLD_FALLING = -5 # Default trend threshold for falling prices (%, negative value)
# Home types
HOME_TYPE_APARTMENT = "APARTMENT"

View file

@ -413,7 +413,8 @@ def aggregate_period_ratings(
def calculate_price_trend(
current_price: float,
future_average: float,
threshold_pct: float = 5.0,
threshold_rising: float = 5.0,
threshold_falling: float = -5.0,
) -> tuple[str, float]:
"""
Calculate price trend by comparing current price with future average.
@ -421,7 +422,8 @@ def calculate_price_trend(
Args:
current_price: Current interval price
future_average: Average price of future intervals
threshold_pct: Percentage threshold for stable vs rising/falling (default 5%)
threshold_rising: Percentage threshold for rising trend (positive, default 5%)
threshold_falling: Percentage threshold for falling trend (negative, default -5%)
Returns:
Tuple of (trend_state, difference_percentage)
@ -436,10 +438,11 @@ def calculate_price_trend(
# Calculate percentage difference from current to future
diff_pct = ((future_average - current_price) / current_price) * 100
# Determine trend based on threshold
if diff_pct > threshold_pct:
# Determine trend based on thresholds
# threshold_falling is negative, so we compare with it directly
if diff_pct > threshold_rising:
trend = "rising"
elif diff_pct < -threshold_pct:
elif diff_pct < threshold_falling:
trend = "falling"
else:
trend = "stable"

View file

@ -29,9 +29,13 @@ from .const import (
CONF_EXTENDED_DESCRIPTIONS,
CONF_PRICE_RATING_THRESHOLD_HIGH,
CONF_PRICE_RATING_THRESHOLD_LOW,
CONF_PRICE_TREND_THRESHOLD_FALLING,
CONF_PRICE_TREND_THRESHOLD_RISING,
DEFAULT_EXTENDED_DESCRIPTIONS,
DEFAULT_PRICE_RATING_THRESHOLD_HIGH,
DEFAULT_PRICE_RATING_THRESHOLD_LOW,
DEFAULT_PRICE_TREND_THRESHOLD_FALLING,
DEFAULT_PRICE_TREND_THRESHOLD_RISING,
DOMAIN,
PRICE_LEVEL_MAPPING,
PRICE_RATING_MAPPING,
@ -522,6 +526,7 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
self._value_getter: Callable | None = self._get_value_getter()
self._time_sensitive_remove_listener: Callable | None = None
self._trend_attributes: dict[str, Any] = {} # Sensor-specific trend attributes
self._cached_trend_value: str | None = None # Cache for trend state
async def async_added_to_hass(self) -> None:
"""When entity is added to hass."""
@ -545,8 +550,21 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
@callback
def _handle_time_sensitive_update(self) -> None:
"""Handle time-sensitive update from coordinator."""
# Clear cached trend values on time-sensitive updates
if self.entity_description.key.startswith("price_trend_"):
self._cached_trend_value = None
self._trend_attributes = {}
self.async_write_ha_state()
@callback
def _handle_coordinator_update(self) -> None:
"""Handle updated data from the coordinator."""
# Clear cached trend values when coordinator data changes
if self.entity_description.key.startswith("price_trend_"):
self._cached_trend_value = None
self._trend_attributes = {}
super()._handle_coordinator_update()
def _get_value_getter(self) -> Callable | None:
"""Return the appropriate value getter method based on the sensor type."""
key = self.entity_description.key
@ -1201,6 +1219,11 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
Trend state: "rising" | "falling" | "stable", or None if unavailable
"""
# Return cached value if available to ensure consistency between
# native_value and extra_state_attributes
if self._cached_trend_value is not None and self._trend_attributes:
return self._cached_trend_value
if not self.coordinator.data:
return None
@ -1223,15 +1246,29 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
if future_avg is None:
return None
# Calculate trend with 5% threshold
trend_state, diff_pct = calculate_price_trend(current_price, future_avg, threshold_pct=5.0)
# Get configured thresholds from options
threshold_rising = self.coordinator.config_entry.options.get(
CONF_PRICE_TREND_THRESHOLD_RISING,
DEFAULT_PRICE_TREND_THRESHOLD_RISING,
)
threshold_falling = self.coordinator.config_entry.options.get(
CONF_PRICE_TREND_THRESHOLD_FALLING,
DEFAULT_PRICE_TREND_THRESHOLD_FALLING,
)
# Store attributes in sensor-specific dictionary (not shared _attr_extra_state_attributes)
# Calculate trend with configured thresholds
trend_state, diff_pct = calculate_price_trend(
current_price, future_avg, threshold_rising=threshold_rising, threshold_falling=threshold_falling
)
# Store attributes in sensor-specific dictionary AND cache the trend value
self._trend_attributes = {
"timestamp": next_interval_start.isoformat(),
f"trend_{hours}h_%": round(diff_pct, 1),
f"future_avg_{hours}h": round(future_avg * 100, 2),
"intervals_analyzed": hours * 4,
"interval_count": hours * 4,
"threshold_rising": threshold_rising,
"threshold_falling": threshold_falling,
}
# Calculate additional attributes for better granularity
@ -1243,8 +1280,11 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
# Calculate incremental change: how much does the later half differ from current?
if current_price > 0:
incremental_diff = ((later_half_avg - current_price) / current_price) * 100
self._trend_attributes["incremental_change"] = round(incremental_diff, 1)
later_half_diff = ((later_half_avg - current_price) / current_price) * 100
self._trend_attributes[f"later_half_diff_{hours}h_%"] = round(later_half_diff, 1)
# Cache the trend value for consistency
self._cached_trend_value = trend_state
return trend_state
@ -1588,7 +1628,8 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
key = self.entity_description.key
attributes = {}
# For trend sensors, merge _trend_attributes (sensor-specific)
# For trend sensors, use the cached _trend_attributes
# These are populated when native_value is calculated
if key.startswith("price_trend_") and hasattr(self, "_trend_attributes") and self._trend_attributes:
attributes.update(self._trend_attributes)

View file

@ -109,6 +109,14 @@
"peak_price_flex": "Flexibilität: Maximale % unter dem Höchstpreis (negativer Wert)",
"peak_price_min_distance_from_avg": "Mindestabstand: Erforderliche % über dem Tagesdurchschnitt"
}
},
"price_trend": {
"title": "Preistrend-Schwellenwerte",
"description": "Konfiguration der Schwellenwerte für Preistrend-Sensoren. Diese Sensoren vergleichen den aktuellen Preis mit dem Durchschnitt der nächsten N Stunden, um festzustellen, ob die Preise steigen, fallen oder stabil sind.",
"data": {
"price_trend_threshold_rising": "Schwellenwert für Steigen (% über aktuellem Preis)",
"price_trend_threshold_falling": "Schwellenwert für Fallen (% unter aktuellem Preis, negativer Wert)"
}
}
},
"error": {

View file

@ -109,6 +109,14 @@
"peak_price_flex": "Flexibility: Maximum % below maximum price (negative value)",
"peak_price_min_distance_from_avg": "Minimum Distance: Required % above daily average"
}
},
"price_trend": {
"title": "Price Trend Thresholds",
"description": "Configure thresholds for price trend sensors. These sensors compare the current price with the average of the next N hours to determine if prices are rising, falling, or stable.",
"data": {
"price_trend_threshold_rising": "Rising Threshold (% above current price)",
"price_trend_threshold_falling": "Falling Threshold (% below current price, negative value)"
}
}
},
"error": {

View file

@ -109,6 +109,14 @@
"peak_price_flex": "Fleksibilitet: Maksimum % under maksimumspris (negativ verdi)",
"peak_price_min_distance_from_avg": "Minimumsavstand: Påkrevd % over daglig gjennomsnitt"
}
},
"price_trend": {
"title": "Terskelverdier for pristrend",
"description": "Konfigurer terskelverdier for pristrendsensorer. Disse sensorene sammenligner gjeldende pris med gjennomsnittet av de neste N timene for å avgjøre om prisene stiger, faller eller er stabile.",
"data": {
"price_trend_threshold_rising": "Stigende terskelverdi (% over gjeldende pris)",
"price_trend_threshold_falling": "Fallende terskelverdi (% under gjeldende pris, negativ verdi)"
}
}
},
"error": {

View file

@ -109,6 +109,14 @@
"peak_price_flex": "Flexibiliteit: Maximaal % onder maximumprijs (negatieve waarde)",
"peak_price_min_distance_from_avg": "Minimale afstand: Vereist % boven dagelijks gemiddelde"
}
},
"price_trend": {
"title": "Prijstrend drempelwaarden",
"description": "Configureer drempelwaarden voor prijstrendsensoren. Deze sensoren vergelijken de huidige prijs met het gemiddelde van de volgende N uur om te bepalen of de prijzen stijgen, dalen of stabiel zijn.",
"data": {
"price_trend_threshold_rising": "Stijgende drempelwaarde (% boven huidige prijs)",
"price_trend_threshold_falling": "Dalende drempelwaarde (% onder huidige prijs, negatieve waarde)"
}
}
},
"error": {

View file

@ -109,6 +109,14 @@
"peak_price_flex": "Flexibilitet: Maximalt % under maximumpris (negativt värde)",
"peak_price_min_distance_from_avg": "Minimiavstånd: Krävd % över dagligt genomsnitt"
}
},
"price_trend": {
"title": "Pristrend tröskelvärden",
"description": "Konfigurera tröskelvärden för pristrendsensorer. Dessa sensorer jämför det aktuella priset med genomsnittet av de nästa N timmarna för att avgöra om priserna stiger, faller eller är stabila.",
"data": {
"price_trend_threshold_rising": "Stigande tröskelvärde (% över aktuellt pris)",
"price_trend_threshold_falling": "Fallande tröskelvärde (% under aktuellt pris, negativt värde)"
}
}
},
"error": {