add lots of new sensors

This commit is contained in:
Julian Pawlowski 2025-11-03 20:55:28 +00:00
parent aaa66227d4
commit bba5f180b0
11 changed files with 1667 additions and 199 deletions

View file

@ -9,7 +9,7 @@ repos:
# Run the Ruff linter with auto-fix
- id: ruff
name: ruff
entry: ruff check --fix --exit-non-zero-on-fix
entry: bash -c 'source $HOME/.venv/bin/activate && ruff check --fix --exit-non-zero-on-fix "$@"' --
language: system
types: [python]
require_serial: true
@ -17,7 +17,7 @@ repos:
# Run the Ruff formatter
- id: ruff-format
name: ruff format
entry: ruff format
entry: bash -c 'source $HOME/.venv/bin/activate && ruff format "$@"' --
language: system
types: [python]
require_serial: true

View file

@ -0,0 +1,491 @@
"""Utility functions for calculating price averages."""
from __future__ import annotations
from datetime import datetime, timedelta
from homeassistant.util import dt as dt_util
def calculate_trailing_24h_avg(all_prices: list[dict], interval_start: datetime) -> float:
"""
Calculate trailing 24-hour average price for a given interval.
Args:
all_prices: List of all price data (yesterday, today, tomorrow combined)
interval_start: Start time of the interval to calculate average for
Returns:
Average price for the 24 hours preceding the interval (not including the interval itself)
"""
# Define the 24-hour window: from 24 hours before interval_start up to interval_start
window_start = interval_start - timedelta(hours=24)
window_end = interval_start
# Filter prices within the 24-hour window
prices_in_window = []
for price_data in all_prices:
starts_at = dt_util.parse_datetime(price_data["startsAt"])
if starts_at is None:
continue
starts_at = dt_util.as_local(starts_at)
# Include intervals that start within the window (not including the current interval's end)
if window_start <= starts_at < window_end:
prices_in_window.append(float(price_data["total"]))
# Calculate average
if prices_in_window:
return sum(prices_in_window) / len(prices_in_window)
return 0.0
def calculate_leading_24h_avg(all_prices: list[dict], interval_start: datetime) -> float:
"""
Calculate leading 24-hour average price for a given interval.
Args:
all_prices: List of all price data (yesterday, today, tomorrow combined)
interval_start: Start time of the interval to calculate average for
Returns:
Average price for up to 24 hours following the interval (including the interval itself)
"""
# Define the 24-hour window: from interval_start up to 24 hours after
window_start = interval_start
window_end = interval_start + timedelta(hours=24)
# Filter prices within the 24-hour window
prices_in_window = []
for price_data in all_prices:
starts_at = dt_util.parse_datetime(price_data["startsAt"])
if starts_at is None:
continue
starts_at = dt_util.as_local(starts_at)
# Include intervals that start within the window
if window_start <= starts_at < window_end:
prices_in_window.append(float(price_data["total"]))
# Calculate average
if prices_in_window:
return sum(prices_in_window) / len(prices_in_window)
return 0.0
def calculate_current_trailing_avg(coordinator_data: dict) -> float | None:
"""
Calculate the trailing 24-hour average for the current time.
Args:
coordinator_data: The coordinator data containing priceInfo
Returns:
Current trailing 24-hour average price, or None if unavailable
"""
if not coordinator_data:
return None
price_info = coordinator_data.get("priceInfo", {})
yesterday_prices = price_info.get("yesterday", [])
today_prices = price_info.get("today", [])
tomorrow_prices = price_info.get("tomorrow", [])
all_prices = yesterday_prices + today_prices + tomorrow_prices
if not all_prices:
return None
now = dt_util.now()
return calculate_trailing_24h_avg(all_prices, now)
def calculate_current_leading_avg(coordinator_data: dict) -> float | None:
"""
Calculate the leading 24-hour average for the current time.
Args:
coordinator_data: The coordinator data containing priceInfo
Returns:
Current leading 24-hour average price, or None if unavailable
"""
if not coordinator_data:
return None
price_info = coordinator_data.get("priceInfo", {})
yesterday_prices = price_info.get("yesterday", [])
today_prices = price_info.get("today", [])
tomorrow_prices = price_info.get("tomorrow", [])
all_prices = yesterday_prices + today_prices + tomorrow_prices
if not all_prices:
return None
now = dt_util.now()
return calculate_leading_24h_avg(all_prices, now)
def calculate_trailing_24h_min(all_prices: list[dict], interval_start: datetime) -> float:
"""
Calculate trailing 24-hour minimum price for a given interval.
Args:
all_prices: List of all price data (yesterday, today, tomorrow combined)
interval_start: Start time of the interval to calculate minimum for
Returns:
Minimum price for the 24 hours preceding the interval (not including the interval itself)
"""
# Define the 24-hour window: from 24 hours before interval_start up to interval_start
window_start = interval_start - timedelta(hours=24)
window_end = interval_start
# Filter prices within the 24-hour window
prices_in_window = []
for price_data in all_prices:
starts_at = dt_util.parse_datetime(price_data["startsAt"])
if starts_at is None:
continue
starts_at = dt_util.as_local(starts_at)
# Include intervals that start within the window (not including the current interval's end)
if window_start <= starts_at < window_end:
prices_in_window.append(float(price_data["total"]))
# Calculate minimum
if prices_in_window:
return min(prices_in_window)
return 0.0
def calculate_trailing_24h_max(all_prices: list[dict], interval_start: datetime) -> float:
"""
Calculate trailing 24-hour maximum price for a given interval.
Args:
all_prices: List of all price data (yesterday, today, tomorrow combined)
interval_start: Start time of the interval to calculate maximum for
Returns:
Maximum price for the 24 hours preceding the interval (not including the interval itself)
"""
# Define the 24-hour window: from 24 hours before interval_start up to interval_start
window_start = interval_start - timedelta(hours=24)
window_end = interval_start
# Filter prices within the 24-hour window
prices_in_window = []
for price_data in all_prices:
starts_at = dt_util.parse_datetime(price_data["startsAt"])
if starts_at is None:
continue
starts_at = dt_util.as_local(starts_at)
# Include intervals that start within the window (not including the current interval's end)
if window_start <= starts_at < window_end:
prices_in_window.append(float(price_data["total"]))
# Calculate maximum
if prices_in_window:
return max(prices_in_window)
return 0.0
def calculate_leading_24h_min(all_prices: list[dict], interval_start: datetime) -> float:
"""
Calculate leading 24-hour minimum price for a given interval.
Args:
all_prices: List of all price data (yesterday, today, tomorrow combined)
interval_start: Start time of the interval to calculate minimum for
Returns:
Minimum price for up to 24 hours following the interval (including the interval itself)
"""
# Define the 24-hour window: from interval_start up to 24 hours after
window_start = interval_start
window_end = interval_start + timedelta(hours=24)
# Filter prices within the 24-hour window
prices_in_window = []
for price_data in all_prices:
starts_at = dt_util.parse_datetime(price_data["startsAt"])
if starts_at is None:
continue
starts_at = dt_util.as_local(starts_at)
# Include intervals that start within the window
if window_start <= starts_at < window_end:
prices_in_window.append(float(price_data["total"]))
# Calculate minimum
if prices_in_window:
return min(prices_in_window)
return 0.0
def calculate_leading_24h_max(all_prices: list[dict], interval_start: datetime) -> float:
"""
Calculate leading 24-hour maximum price for a given interval.
Args:
all_prices: List of all price data (yesterday, today, tomorrow combined)
interval_start: Start time of the interval to calculate maximum for
Returns:
Maximum price for up to 24 hours following the interval (including the interval itself)
"""
# Define the 24-hour window: from interval_start up to 24 hours after
window_start = interval_start
window_end = interval_start + timedelta(hours=24)
# Filter prices within the 24-hour window
prices_in_window = []
for price_data in all_prices:
starts_at = dt_util.parse_datetime(price_data["startsAt"])
if starts_at is None:
continue
starts_at = dt_util.as_local(starts_at)
# Include intervals that start within the window
if window_start <= starts_at < window_end:
prices_in_window.append(float(price_data["total"]))
# Calculate maximum
if prices_in_window:
return max(prices_in_window)
return 0.0
def calculate_current_trailing_min(coordinator_data: dict) -> float | None:
"""
Calculate the trailing 24-hour minimum for the current time.
Args:
coordinator_data: The coordinator data containing priceInfo
Returns:
Current trailing 24-hour minimum price, or None if unavailable
"""
if not coordinator_data:
return None
price_info = coordinator_data.get("priceInfo", {})
yesterday_prices = price_info.get("yesterday", [])
today_prices = price_info.get("today", [])
tomorrow_prices = price_info.get("tomorrow", [])
all_prices = yesterday_prices + today_prices + tomorrow_prices
if not all_prices:
return None
now = dt_util.now()
return calculate_trailing_24h_min(all_prices, now)
def calculate_current_trailing_max(coordinator_data: dict) -> float | None:
"""
Calculate the trailing 24-hour maximum for the current time.
Args:
coordinator_data: The coordinator data containing priceInfo
Returns:
Current trailing 24-hour maximum price, or None if unavailable
"""
if not coordinator_data:
return None
price_info = coordinator_data.get("priceInfo", {})
yesterday_prices = price_info.get("yesterday", [])
today_prices = price_info.get("today", [])
tomorrow_prices = price_info.get("tomorrow", [])
all_prices = yesterday_prices + today_prices + tomorrow_prices
if not all_prices:
return None
now = dt_util.now()
return calculate_trailing_24h_max(all_prices, now)
def calculate_current_leading_min(coordinator_data: dict) -> float | None:
"""
Calculate the leading 24-hour minimum for the current time.
Args:
coordinator_data: The coordinator data containing priceInfo
Returns:
Current leading 24-hour minimum price, or None if unavailable
"""
if not coordinator_data:
return None
price_info = coordinator_data.get("priceInfo", {})
yesterday_prices = price_info.get("yesterday", [])
today_prices = price_info.get("today", [])
tomorrow_prices = price_info.get("tomorrow", [])
all_prices = yesterday_prices + today_prices + tomorrow_prices
if not all_prices:
return None
now = dt_util.now()
return calculate_leading_24h_min(all_prices, now)
def calculate_current_leading_max(coordinator_data: dict) -> float | None:
"""
Calculate the leading 24-hour maximum for the current time.
Args:
coordinator_data: The coordinator data containing priceInfo
Returns:
Current leading 24-hour maximum price, or None if unavailable
"""
if not coordinator_data:
return None
price_info = coordinator_data.get("priceInfo", {})
yesterday_prices = price_info.get("yesterday", [])
today_prices = price_info.get("today", [])
tomorrow_prices = price_info.get("tomorrow", [])
all_prices = yesterday_prices + today_prices + tomorrow_prices
if not all_prices:
return None
now = dt_util.now()
return calculate_leading_24h_max(all_prices, now)
def calculate_current_rolling_5interval_avg(coordinator_data: dict) -> float | None:
"""
Calculate rolling 5-interval average (2 previous + current + 2 next intervals).
This provides a smoothed "hour price" that adapts as time moves, rather than
being fixed to clock hours. With 15-minute intervals, this covers a 75-minute
window (37.5 minutes before and after the current interval).
Args:
coordinator_data: The coordinator data containing priceInfo
Returns:
Average price of the 5 intervals, or None if unavailable
"""
if not coordinator_data:
return None
price_info = coordinator_data.get("priceInfo", {})
yesterday_prices = price_info.get("yesterday", [])
today_prices = price_info.get("today", [])
tomorrow_prices = price_info.get("tomorrow", [])
all_prices = yesterday_prices + today_prices + tomorrow_prices
if not all_prices:
return None
now = dt_util.now()
# Find the current interval
current_idx = None
for idx, price_data in enumerate(all_prices):
starts_at = dt_util.parse_datetime(price_data["startsAt"])
if starts_at is None:
continue
starts_at = dt_util.as_local(starts_at)
interval_end = starts_at + timedelta(minutes=15)
if starts_at <= now < interval_end:
current_idx = idx
break
if current_idx is None:
return None
# Collect prices from 2 intervals before to 2 intervals after (5 total)
prices_in_window = []
for offset in range(-2, 3): # -2, -1, 0, 1, 2
idx = current_idx + offset
if 0 <= idx < len(all_prices):
price = all_prices[idx].get("total")
if price is not None:
prices_in_window.append(float(price))
# Calculate average
if prices_in_window:
return sum(prices_in_window) / len(prices_in_window)
return None
def calculate_next_hour_rolling_5interval_avg(coordinator_data: dict) -> float | None:
"""
Calculate rolling 5-interval average for the next hour (shifted by 4 intervals).
This provides the same smoothed "hour price" as the current hour sensor, but
looks ahead to the next hour. With 15-minute intervals, this shifts the
5-interval window forward by 60 minutes (4 intervals).
Args:
coordinator_data: The coordinator data containing priceInfo
Returns:
Average price of the 5 intervals one hour ahead, or None if unavailable
"""
if not coordinator_data:
return None
price_info = coordinator_data.get("priceInfo", {})
yesterday_prices = price_info.get("yesterday", [])
today_prices = price_info.get("today", [])
tomorrow_prices = price_info.get("tomorrow", [])
all_prices = yesterday_prices + today_prices + tomorrow_prices
if not all_prices:
return None
now = dt_util.now()
# Find the current interval
current_idx = None
for idx, price_data in enumerate(all_prices):
starts_at = dt_util.parse_datetime(price_data["startsAt"])
if starts_at is None:
continue
starts_at = dt_util.as_local(starts_at)
interval_end = starts_at + timedelta(minutes=15)
if starts_at <= now < interval_end:
current_idx = idx
break
if current_idx is None:
return None
# Shift forward by 4 intervals (1 hour) to get the "next hour" center point
next_hour_idx = current_idx + 4
# Collect prices from 2 intervals before to 2 intervals after the next hour center (5 total)
# This means: current_idx + 2, +3, +4, +5, +6
prices_in_window = []
for offset in range(-2, 3): # -2, -1, 0, 1, 2 relative to next_hour_idx
idx = next_hour_idx + offset
if 0 <= idx < len(all_prices):
price = all_prices[idx].get("total")
if price is not None:
prices_in_window.append(float(price))
# Calculate average
if prices_in_window:
return sum(prices_in_window) / len(prices_in_window)
return None

View file

@ -13,6 +13,7 @@ from homeassistant.components.binary_sensor import (
from homeassistant.const import PERCENTAGE, EntityCategory
from homeassistant.util import dt as dt_util
from .average_utils import calculate_leading_24h_avg, calculate_trailing_24h_avg
from .entity import TibberPricesEntity
from .sensor import find_price_data_for_interval
@ -286,6 +287,26 @@ class TibberPricesBinarySensor(TibberPricesEntity, BinarySensorEntity):
else 0.0
)
new_interval["price_diff_from_avg_" + PERCENTAGE] = round(avg_diff_percent, 2)
# Calculate difference from trailing 24-hour average
trailing_avg = annotation_ctx.get("trailing_24h_avg", 0.0)
trailing_avg_diff = new_interval["price"] - trailing_avg
new_interval["price_diff_from_trailing_24h_avg"] = round(trailing_avg_diff, 4)
new_interval["price_diff_from_trailing_24h_avg_ct"] = round(trailing_avg_diff * 100, 2)
trailing_avg_diff_percent = (
((new_interval["price"] - trailing_avg) / trailing_avg) * 100 if trailing_avg != 0 else 0.0
)
new_interval["price_diff_from_trailing_24h_avg_" + PERCENTAGE] = round(trailing_avg_diff_percent, 2)
new_interval["trailing_24h_avg_price"] = round(trailing_avg, 4)
# Calculate difference from leading 24-hour average
leading_avg = annotation_ctx.get("leading_24h_avg", 0.0)
leading_avg_diff = new_interval["price"] - leading_avg
new_interval["price_diff_from_leading_24h_avg"] = round(leading_avg_diff, 4)
new_interval["price_diff_from_leading_24h_avg_ct"] = round(leading_avg_diff * 100, 2)
leading_avg_diff_percent = (
((new_interval["price"] - leading_avg) / leading_avg) * 100 if leading_avg != 0 else 0.0
)
new_interval["price_diff_from_leading_24h_avg_" + PERCENTAGE] = round(leading_avg_diff_percent, 2)
new_interval["leading_24h_avg_price"] = round(leading_avg, 4)
return new_interval
def _annotate_period_intervals(
@ -293,6 +314,7 @@ class TibberPricesBinarySensor(TibberPricesEntity, BinarySensorEntity):
periods: list[list[dict]],
ref_prices: dict,
avg_price_by_day: dict,
all_prices: list[dict],
) -> list[dict]:
"""
Return flattened and annotated intervals with period info and requested properties.
@ -334,6 +356,10 @@ class TibberPricesBinarySensor(TibberPricesEntity, BinarySensorEntity):
interval_date = interval_start.date() if interval_start else None
avg_price = avg_price_by_day.get(interval_date, 0)
ref_price = ref_prices.get(interval_date, 0)
# Calculate trailing 24-hour average for this interval
trailing_24h_avg = calculate_trailing_24h_avg(all_prices, interval_start) if interval_start else 0.0
# Calculate leading 24-hour average for this interval
leading_24h_avg = calculate_leading_24h_avg(all_prices, interval_start) if interval_start else 0.0
annotation_ctx = {
"period_start": period_start,
"period_end": period_end,
@ -348,6 +374,8 @@ class TibberPricesBinarySensor(TibberPricesEntity, BinarySensorEntity):
"period_idx": period_idx,
"ref_price": ref_price,
"avg_price": avg_price,
"trailing_24h_avg": trailing_24h_avg,
"leading_24h_avg": leading_24h_avg,
"diff_key": diff_key,
"diff_ct_key": diff_ct_key,
"diff_pct_key": diff_pct_key,
@ -512,6 +540,7 @@ class TibberPricesBinarySensor(TibberPricesEntity, BinarySensorEntity):
filtered_periods,
ref_prices,
avg_price_by_day,
all_prices,
)
filtered_result = self._filter_intervals_today_tomorrow(result)
current_interval = self._find_current_or_next_interval(filtered_result)

View file

@ -1,29 +1,35 @@
{
"sensor": {
"current_price": {
"name": "Aktueller Strompreis",
"description": "Der aktuelle Strompreis in Euro",
"long_description": "Zeigt den aktuellen Preis pro kWh (in Euro) von deinem Tibber-Abonnement an",
"usage_tips": "Nutze dies, um Preise zu verfolgen oder Automatisierungen zu erstellen, die bei günstigem Strom ausgeführt werden"
},
"current_price_cents": {
"name": "Aktueller Strompreis",
"description": "Der aktuelle Strompreis in Cent pro kWh",
"long_description": "Zeigt den aktuellen Preis pro kWh (in Cent) von deinem Tibber-Abonnement an",
"usage_tips": "Nutze dies, um Preise zu verfolgen oder Automatisierungen zu erstellen, die bei günstigem Strom ausgeführt werden"
},
"next_interval_price": {
"name": "Strompreis nächstes Intervall",
"description": "Der Strompreis für das nächste 15-Minuten-Intervall in Euro",
"long_description": "Zeigt den Preis für das nächste 15-Minuten-Intervall (in Euro) von deinem Tibber-Abonnement an",
"usage_tips": "Nutze dies, um dich auf kommende Preisänderungen vorzubereiten oder Geräte während günstigerer Intervalle zu planen"
},
"next_interval_price_cents": {
"name": "Strompreis nächstes Intervall",
"name": "Nächster Preis",
"description": "Der Strompreis für das nächste 15-Minuten-Intervall in Cent pro kWh",
"long_description": "Zeigt den Preis für das nächste 15-Minuten-Intervall (in Cent) von deinem Tibber-Abonnement an",
"usage_tips": "Nutze dies, um dich auf kommende Preisänderungen vorzubereiten oder Geräte während günstigerer Intervalle zu planen"
},
"previous_interval_price_cents": {
"name": "Vorheriger Preis",
"description": "Der Strompreis für das vorherige 15-Minuten-Intervall in Cent pro kWh",
"long_description": "Zeigt den Preis für das vorherige 15-Minuten-Intervall (in Cent) von deinem Tibber-Abonnement an",
"usage_tips": "Nutze dies, um vergangene Preisänderungen zu überprüfen oder den Preisverlauf zu verfolgen"
},
"current_hour_average_cents": {
"name": "Aktueller Stunden-Durchschnittspreis",
"description": "Gleitender 5-Intervall-Durchschnittspreis in Cent pro kWh",
"long_description": "Zeigt den durchschnittlichen Preis pro kWh (in Cent) berechnet aus 5 Intervallen: 2 vorherige, aktuelles und 2 nächste Intervalle (ca. 75 Minuten insgesamt). Dies bietet einen geglätteten 'Stundenpreis', der sich mit der Zeit anpasst, anstatt an feste Uhrzeiten gebunden zu sein.",
"usage_tips": "Nutze dies für einen stabileren Preisindikator, der kurzfristige Schwankungen glättet und dennoch auf Preisänderungen reagiert. Besser als feste Stundenpreise für Verbrauchsentscheidungen."
},
"next_hour_average_cents": {
"name": "Nächster Stunden-Durchschnittspreis",
"description": "Gleitender 5-Intervall-Durchschnittspreis für nächste Stunde in Cent pro kWh",
"long_description": "Zeigt den durchschnittlichen Preis pro kWh (in Cent) berechnet aus 5 Intervallen, die eine Stunde voraus zentriert sind: ungefähr Intervalle +2 bis +6 von jetzt (Minuten +30 bis +105 abdeckend). Dies bietet einen vorausschauenden geglätteten 'Stundenpreis' zur Verbrauchsplanung.",
"usage_tips": "Nutze dies, um Preisänderungen in der nächsten Stunde vorherzusehen. Hilfreich für die Planung von verbrauchsintensiven Aktivitäten wie Elektrofahrzeug-Laden, Geschirrspüler oder Heizsysteme."
},
"price_level": {
"name": "Aktuelles Preisniveau",
"description": "Die aktuelle Preislevelklassifikation",
@ -37,42 +43,102 @@
"VERY_EXPENSIVE": "Sehr teuer"
}
},
"lowest_price_today": {
"name": "Niedrigster Preis heute",
"description": "Der niedrigste Strompreis für heute in Euro",
"long_description": "Zeigt den niedrigsten Preis pro kWh (in Euro) für den aktuellen Tag von deinem Tibber-Abonnement an",
"usage_tips": "Nutze dies, um aktuelle Preise mit der günstigsten Zeit des Tages zu vergleichen"
"next_interval_price_level": {
"name": "Nächstes Preisniveau",
"description": "Preisniveau für das nächste 15-Minuten-Intervall",
"long_description": "Zeigt die Preisniveau-Klassifizierung für das kommende Intervall an. Hilft bei der Antizipation kurzfristiger Preisänderungen für sofortige Planung.",
"usage_tips": "Nutze dies für schnelle Entscheidungen über das Starten oder Stoppen von Hochleistungsgeräten in den nächsten Minuten."
},
"previous_interval_price_level": {
"name": "Vorheriges Preisniveau",
"description": "Preisniveau für das vorherige 15-Minuten-Intervall",
"long_description": "Zeigt die Preisniveau-Klassifizierung für das letzte Intervall an. Nützlich für historische Vergleiche und das Verständnis jüngster Preistrends.",
"usage_tips": "Nutze dies, um aktuelle mit kürzlichen Preisniveaus zu vergleichen oder Verbrauchsmuster gegen historische Preise zu analysieren."
},
"current_hour_price_level": {
"name": "Aktuells Stunden-Preisniveau",
"description": "Aggregiertes Preisniveau für aktuelle gleitende Stunde (5 Intervalle)",
"long_description": "Zeigt das mediane Preisniveau über 5 Intervalle (2 vorherige, aktuelles, 2 nächste) mit ca. 75 Minuten Abdeckung. Bietet einen stabileren Preisniveau-Indikator, der kurzfristige Schwankungen glättet.",
"usage_tips": "Nutze dies für mittelfristige Planungsentscheidungen, wenn du nicht auf kurze Preisspitzen oder -einbrüche reagieren möchtest."
},
"next_hour_price_level": {
"name": "Nächstes Stunden-Preisniveau",
"description": "Aggregiertes Preisniveau für nächste gleitende Stunde (5 Intervalle voraus)",
"long_description": "Zeigt das mediane Preisniveau über 5 Intervalle, die eine Stunde voraus zentriert sind. Hilft bei der Verbrauchsplanung basierend auf kommenden Preistrends statt momentanen zukünftigen Preisen.",
"usage_tips": "Nutze dies, um Aktivitäten für die nächste Stunde basierend auf einer geglätteten Preisniveau-Prognose zu planen."
},
"lowest_price_today_cents": {
"name": "Niedrigster Preis heute",
"name": "Mindestpreis heute",
"description": "Der niedrigste Strompreis für heute in Cent pro kWh",
"long_description": "Zeigt den niedrigsten Preis pro kWh (in Cent) für den aktuellen Tag von deinem Tibber-Abonnement an",
"usage_tips": "Nutze dies, um aktuelle Preise mit der günstigsten Zeit des Tages zu vergleichen"
},
"highest_price_today": {
"name": "Höchster Preis heute",
"description": "Der höchste Strompreis für heute in Euro",
"long_description": "Zeigt den höchsten Preis pro kWh (in Euro) für den aktuellen Tag von deinem Tibber-Abonnement an",
"usage_tips": "Nutze dies, um den Betrieb von Geräten während Spitzenpreiszeiten zu vermeiden"
},
"highest_price_today_cents": {
"name": "Höchster Preis heute",
"name": "Höchstpreis heute",
"description": "Der höchste Strompreis für heute in Cent pro kWh",
"long_description": "Zeigt den höchsten Preis pro kWh (in Cent) für den aktuellen Tag von deinem Tibber-Abonnement an",
"usage_tips": "Nutze dies, um den Betrieb von Geräten während Spitzenpreiszeiten zu vermeiden"
},
"average_price_today": {
"name": "Durchschnittspreis heute",
"description": "Der durchschnittliche Strompreis für heute in Euro",
"long_description": "Zeigt den durchschnittlichen Preis pro kWh (in Euro) für den aktuellen Tag von deinem Tibber-Abonnement an",
"usage_tips": "Nutze dies als Grundlage für den Vergleich mit aktuellen Preisen"
},
"average_price_today_cents": {
"name": "Durchschnittspreis heute",
"description": "Der durchschnittliche Strompreis für heute in Cent pro kWh",
"long_description": "Zeigt den durchschnittlichen Preis pro kWh (in Cent) für den aktuellen Tag von deinem Tibber-Abonnement an",
"usage_tips": "Nutze dies als Grundlage für den Vergleich mit aktuellen Preisen"
},
"lowest_price_tomorrow_cents": {
"name": "Mindestpreis morgen",
"description": "Der niedrigste Strompreis für morgen in Cent pro kWh",
"long_description": "Zeigt den niedrigsten Preis pro kWh (in Cent) für den morgigen Tag von deinem Tibber-Abonnement an. Dieser Sensor wird nicht verfügbar, bis die Preise für morgen von Tibber veröffentlicht werden (typischerweise zwischen 13:00 und 14:00 Uhr MEZ).",
"usage_tips": "Nutze dies zur Planung energieintensiver Aktivitäten wie das Laden von Elektrofahrzeugen oder das Aufheizen von Warmwasserspeichern für morgen. Wenn der morgige Mindestpreis deutlich niedriger ist als der heutige, kannst du den Verbrauch verschieben."
},
"highest_price_tomorrow_cents": {
"name": "Höchstpreis morgen",
"description": "Der höchste Strompreis für morgen in Cent pro kWh",
"long_description": "Zeigt den höchsten Preis pro kWh (in Cent) für den morgigen Tag von deinem Tibber-Abonnement an. Dieser Sensor wird nicht verfügbar, bis die Preise für morgen von Tibber veröffentlicht werden (typischerweise zwischen 13:00 und 14:00 Uhr MEZ).",
"usage_tips": "Nutze dies, um den Betrieb von Geräten während der teuersten Stunden morgen zu vermeiden. Plane nicht-essentielle Lasten außerhalb dieser Spitzenpreiszeiten."
},
"average_price_tomorrow_cents": {
"name": "Durchschnittspreis morgen",
"description": "Der durchschnittliche Strompreis für morgen in Cent pro kWh",
"long_description": "Zeigt den durchschnittlichen Preis pro kWh (in Cent) für den morgigen Tag von deinem Tibber-Abonnement an. Dieser Sensor wird nicht verfügbar, bis die Preise für morgen von Tibber veröffentlicht werden (typischerweise zwischen 13:00 und 14:00 Uhr MEZ).",
"usage_tips": "Nutze dies als Grundlage für den Vergleich der morgigen Preise mit dem heutigen Durchschnitt. Wenn der morgige Durchschnitt niedriger ist, kann es besser sein, energieintensive Aufgaben bis morgen zu verschieben."
},
"trailing_price_average_cents": {
"name": "Nachlaufender 24h-Durchschnittspreis",
"description": "Der durchschnittliche Strompreis der letzten 24 Stunden in Cent pro kWh",
"long_description": "Zeigt den durchschnittlichen Preis pro kWh (in Cent) berechnet aus den letzten 24 Stunden (nachlaufender Durchschnitt) von deinem Tibber-Abonnement an. Dies bietet einen rollierenden Durchschnitt, der alle 15 Minuten auf Basis historischer Daten aktualisiert wird.",
"usage_tips": "Nutze dies, um aktuelle Preise mit aktuellen Trends zu vergleichen. Ein aktueller Preis, der deutlich über diesem Durchschnitt liegt, kann ein guter Zeitpunkt sein, den Verbrauch zu reduzieren."
},
"leading_price_average_cents": {
"name": "Vorlaufender 24h-Durchschnittspreis",
"description": "Der durchschnittliche Strompreis für die nächsten 24 Stunden in Cent pro kWh",
"long_description": "Zeigt den durchschnittlichen Preis pro kWh (in Cent) berechnet aus den nächsten 24 Stunden (vorlaufender Durchschnitt) von deinem Tibber-Abonnement an. Dies bietet einen zukunftsgerichteten Durchschnitt basierend auf verfügbaren Prognosedaten.",
"usage_tips": "Nutze dies zur Planung des Energieverbrauchs. Wenn der aktuelle Preis unter dem vorlaufenden Durchschnitt liegt, kann es ein guter Zeitpunkt sein, energieintensive Geräte zu betreiben."
},
"trailing_price_min_cents": {
"name": "Nachlaufender 24h-Mindestpreis",
"description": "Der minimale Strompreis der letzten 24 Stunden in Cent pro kWh",
"long_description": "Zeigt den minimalen Preis pro kWh (in Cent) aus den letzten 24 Stunden (nachlaufendes Minimum) von deinem Tibber-Abonnement an. Dies zeigt den niedrigsten Preis der letzten 24 Stunden.",
"usage_tips": "Nutze dies, um die beste Preisgelegenheit der letzten 24 Stunden zu sehen und mit aktuellen Preisen zu vergleichen."
},
"trailing_price_max_cents": {
"name": "Nachlaufender 24h-Höchstpreis",
"description": "Der maximale Strompreis der letzten 24 Stunden in Cent pro kWh",
"long_description": "Zeigt den maximalen Preis pro kWh (in Cent) aus den letzten 24 Stunden (nachlaufendes Maximum) von deinem Tibber-Abonnement an. Dies zeigt den höchsten Preis der letzten 24 Stunden.",
"usage_tips": "Nutze dies, um den Spitzenpreis der letzten 24 Stunden zu sehen und die Preisvolatilität zu bewerten."
},
"leading_price_min_cents": {
"name": "Vorlaufender 24h-Mindestpreis",
"description": "Der minimale Strompreis für die nächsten 24 Stunden in Cent pro kWh",
"long_description": "Zeigt den minimalen Preis pro kWh (in Cent) für die nächsten 24 Stunden (vorlaufendes Minimum) von deinem Tibber-Abonnement an. Dies zeigt den niedrigsten erwarteten Preis der nächsten 24 Stunden basierend auf Prognosedaten.",
"usage_tips": "Nutze dies, um die beste kommende Preisgelegenheit zu identifizieren und energieintensive Aufgaben entsprechend zu planen."
},
"leading_price_max_cents": {
"name": "Vorlaufender 24h-Höchstpreis",
"description": "Der maximale Strompreis für die nächsten 24 Stunden in Cent pro kWh",
"long_description": "Zeigt den maximalen Preis pro kWh (in Cent) für die nächsten 24 Stunden (vorlaufendes Maximum) von deinem Tibber-Abonnement an. Dies zeigt den höchsten erwarteten Preis der nächsten 24 Stunden basierend auf Prognosedaten.",
"usage_tips": "Nutze dies, um den Betrieb von Geräten während kommender Spitzenpreiszeiten zu vermeiden."
},
"price_rating": {
"name": "Aktuelle Preisbewertung",
"description": "Wie sich der Preis des aktuellen Intervalls mit historischen Daten vergleicht",
@ -84,6 +150,30 @@
"HIGH": "Hoch"
}
},
"next_interval_price_rating": {
"name": "Nächste Preisbewertung",
"description": "Preisbewertung für das nächste 15-Minuten-Intervall",
"long_description": "Zeigt, wie sich der Preis des nächsten Intervalls im Vergleich zum gleitenden 24-Stunden-Durchschnitt verhält. Hilft vorherzusehen, ob kommende Preise über oder unter dem jüngsten Trend liegen.",
"usage_tips": "Nutze dies für schnelle Entscheidungen über das Starten von Aktivitäten im nächsten Intervall basierend auf der relativen Preisposition."
},
"previous_interval_price_rating": {
"name": "Vorherige Preisbewertung",
"description": "Preisbewertung für das vorherige 15-Minuten-Intervall",
"long_description": "Zeigt, wie sich der Preis des letzten Intervalls im Vergleich zum gleitenden Durchschnitt verhielt. Nützlich für das Verständnis des jüngsten Preisverhaltens.",
"usage_tips": "Nutze dies zur Analyse, wie sich Preisbewertungen im Laufe der Zeit verändert haben oder um vergangene Verbrauchsentscheidungen zu validieren."
},
"current_hour_price_rating": {
"name": "Aktuelle Stunden-Preisbewertung",
"description": "Aggregierte Preisbewertung für aktuelle gleitende Stunde (5 Intervalle)",
"long_description": "Zeigt die durchschnittliche Bewertung über 5 Intervalle (2 vorherige, aktuelles, 2 nächste). Basierend auf der durchschnittlichen prozentualen Abweichung vom gleitenden 24h-Durchschnitt, bietet dies einen geglätteten Bewertungsindikator.",
"usage_tips": "Nutze dies für stabile mittelfristige Preisbewertung, die nicht auf kurze Preisanomalien überreagiert."
},
"next_hour_price_rating": {
"name": "Nächste Stunden-Preisbewertung",
"description": "Aggregierte Preisbewertung für nächste gleitende Stunde (5 Intervalle voraus)",
"long_description": "Zeigt die gemittelte Bewertung für 5 Intervalle, die eine Stunde voraus zentriert sind. Hilft zu verstehen, ob die nächste Stunde generell über oder unter durchschnittlicher Preisgestaltung liegen wird.",
"usage_tips": "Nutze dies, um zu entscheiden, ob du eine Stunde warten solltest, bevor du verbrauchsintensive Aktivitäten startest."
},
"daily_rating": {
"name": "Tägliche Preisbewertung",
"description": "Wie sich die heutigen Preise mit historischen Daten vergleichen",

View file

@ -1,29 +1,35 @@
{
"sensor": {
"current_price": {
"name": "Current Electricity Price",
"description": "The current electricity price in Euro",
"long_description": "Shows the current price per kWh (in Euro) from your Tibber subscription",
"usage_tips": "Use this to track prices or to create automations that run when electricity is cheap"
},
"current_price_cents": {
"name": "Current Electricity Price",
"description": "The current electricity price in cents per kWh",
"long_description": "Shows the current price per kWh (in cents) from your Tibber subscription",
"usage_tips": "Use this to track prices or to create automations that run when electricity is cheap"
},
"next_interval_price": {
"name": "Next Interval Electricity Price",
"description": "The next interval electricity price in Euro",
"long_description": "Shows the price for the next 15-minute interval (in Euro) from your Tibber subscription",
"usage_tips": "Use this to prepare for upcoming price changes or to schedule devices to run during cheaper intervals"
},
"next_interval_price_cents": {
"name": "Next Interval Electricity Price",
"name": "Next Price",
"description": "The next interval electricity price in cents per kWh",
"long_description": "Shows the price for the next 15-minute interval (in cents) from your Tibber subscription",
"usage_tips": "Use this to prepare for upcoming price changes or to schedule devices to run during cheaper intervals"
},
"previous_interval_price_cents": {
"name": "Previous Electricity Price",
"description": "The previous interval electricity price in cents per kWh",
"long_description": "Shows the price for the previous 15-minute interval (in cents) from your Tibber subscription",
"usage_tips": "Use this to review past price changes or track price history"
},
"current_hour_average_cents": {
"name": "Current Hour Average Price",
"description": "Rolling 5-interval average price in cents per kWh",
"long_description": "Shows the average price per kWh (in cents) calculated from 5 intervals: 2 previous, current, and 2 next intervals (approximately 75 minutes total). This provides a smoothed 'hour price' that adapts as time moves, rather than being fixed to clock hours.",
"usage_tips": "Use this for a more stable price indicator that smooths out short-term fluctuations while still being responsive to price changes. Better than fixed hourly prices for making consumption decisions."
},
"next_hour_average_cents": {
"name": "Next Hour Average Price",
"description": "Rolling 5-interval average price for next hour in cents per kWh",
"long_description": "Shows the average price per kWh (in cents) calculated from 5 intervals centered one hour ahead: approximately intervals +2 through +6 from now (covering minutes +30 to +105). This provides a forward-looking smoothed 'hour price' for planning consumption.",
"usage_tips": "Use this to anticipate price changes in the next hour. Helpful for scheduling high-consumption activities like charging electric vehicles, running dishwashers, or heating systems."
},
"price_level": {
"name": "Current Price Level",
"description": "The current price level classification",
@ -37,11 +43,29 @@
"VERY_EXPENSIVE": "Very Expensive"
}
},
"lowest_price_today": {
"name": "Today's Lowest Price",
"description": "The lowest electricity price for today in Euro",
"long_description": "Shows the lowest price per kWh (in Euro) for the current day from your Tibber subscription",
"usage_tips": "Use this to compare current prices to the cheapest time of the day"
"next_interval_price_level": {
"name": "Next Price Level",
"description": "Price level for the next 15-minute interval",
"long_description": "Shows the price level classification for the upcoming interval. Helps anticipate short-term price changes for immediate planning.",
"usage_tips": "Use for quick decisions about starting or stopping high-power devices in the next few minutes."
},
"previous_interval_price_level": {
"name": "Previous Price Level",
"description": "Price level for the previous 15-minute interval",
"long_description": "Shows the price level classification for the last interval. Useful for historical comparison and understanding recent price trends.",
"usage_tips": "Use to compare current vs recent price levels or analyze consumption patterns against historical prices."
},
"current_hour_price_level": {
"name": "Current Hour Price Level",
"description": "Aggregated price level for current rolling hour (5 intervals)",
"long_description": "Shows the median price level across 5 intervals (2 before, current, 2 after) covering approximately 75 minutes. Provides a more stable price level indicator that smooths out short-term fluctuations.",
"usage_tips": "Use for medium-term planning decisions where you want to avoid reacting to brief price spikes or dips."
},
"next_hour_price_level": {
"name": "Next Hour Price Level",
"description": "Aggregated price level for next rolling hour (5 intervals ahead)",
"long_description": "Shows the median price level across 5 intervals centered one hour ahead. Helps plan consumption based on upcoming price trends rather than instantaneous future prices.",
"usage_tips": "Use to schedule activities for the next hour based on a smoothed price level forecast."
},
"lowest_price_today_cents": {
"name": "Today's Lowest Price",
@ -49,30 +73,72 @@
"long_description": "Shows the lowest price per kWh (in cents) for the current day from your Tibber subscription",
"usage_tips": "Use this to compare current prices to the cheapest time of the day"
},
"highest_price_today": {
"name": "Today's Highest Price",
"description": "The highest electricity price for today in Euro",
"long_description": "Shows the highest price per kWh (in Euro) for the current day from your Tibber subscription",
"usage_tips": "Use this to avoid running appliances during peak price times"
},
"highest_price_today_cents": {
"name": "Today's Highest Price",
"description": "The highest electricity price for today in cents per kWh",
"long_description": "Shows the highest price per kWh (in cents) for the current day from your Tibber subscription",
"usage_tips": "Use this to avoid running appliances during peak price times"
},
"average_price_today": {
"name": "Today's Average Price",
"description": "The average electricity price for today in Euro",
"long_description": "Shows the average price per kWh (in Euro) for the current day from your Tibber subscription",
"usage_tips": "Use this as a baseline for comparing current prices"
},
"average_price_today_cents": {
"name": "Today's Average Price",
"description": "The average electricity price for today in cents per kWh",
"long_description": "Shows the average price per kWh (in cents) for the current day from your Tibber subscription",
"usage_tips": "Use this as a baseline for comparing current prices"
},
"lowest_price_tomorrow_cents": {
"name": "Tomorrow's Lowest Price",
"description": "The lowest electricity price for tomorrow in cents per kWh",
"long_description": "Shows the lowest price per kWh (in cents) for tomorrow from your Tibber subscription. This sensor becomes unavailable until tomorrow's data is published by Tibber (typically around 13:00-14:00 CET).",
"usage_tips": "Use this to plan energy-intensive activities for tomorrow's cheapest time. Perfect for pre-scheduling heating, EV charging, or appliances."
},
"highest_price_tomorrow_cents": {
"name": "Tomorrow's Highest Price",
"description": "The highest electricity price for tomorrow in cents per kWh",
"long_description": "Shows the highest price per kWh (in cents) for tomorrow from your Tibber subscription. This sensor becomes unavailable until tomorrow's data is published by Tibber (typically around 13:00-14:00 CET).",
"usage_tips": "Use this to avoid running appliances during tomorrow's peak price times. Helpful for planning around expensive periods."
},
"average_price_tomorrow_cents": {
"name": "Tomorrow's Average Price",
"description": "The average electricity price for tomorrow in cents per kWh",
"long_description": "Shows the average price per kWh (in cents) for tomorrow from your Tibber subscription. This sensor becomes unavailable until tomorrow's data is published by Tibber (typically around 13:00-14:00 CET).",
"usage_tips": "Use this as a baseline for comparing tomorrow's prices and planning consumption. Compare with today's average to see if tomorrow will be more or less expensive overall."
},
"trailing_price_average_cents": {
"name": "Trailing 24h Average Price",
"description": "The average electricity price for the past 24 hours in cents per kWh",
"long_description": "Shows the average price per kWh (in cents) calculated from the past 24 hours (trailing average) from your Tibber subscription. This provides a rolling average that updates every 15 minutes based on historical data.",
"usage_tips": "Use this to compare current prices against recent trends. A current price significantly above this average may indicate a good time to reduce consumption."
},
"leading_price_average_cents": {
"name": "Leading 24h Average Price",
"description": "The average electricity price for the next 24 hours in cents per kWh",
"long_description": "Shows the average price per kWh (in cents) calculated from the next 24 hours (leading average) from your Tibber subscription. This provides a forward-looking average based on available forecast data.",
"usage_tips": "Use this to plan energy usage. If the current price is below the leading average, it may be a good time to run energy-intensive appliances."
},
"trailing_price_min_cents": {
"name": "Trailing 24h Minimum Price",
"description": "The minimum electricity price for the past 24 hours in cents per kWh",
"long_description": "Shows the minimum price per kWh (in cents) from the past 24 hours (trailing minimum) from your Tibber subscription. This provides the lowest price seen in the last 24 hours.",
"usage_tips": "Use this to see the best price opportunity you had in the past 24 hours and compare it with current prices."
},
"trailing_price_max_cents": {
"name": "Trailing 24h Maximum Price",
"description": "The maximum electricity price for the past 24 hours in cents per kWh",
"long_description": "Shows the maximum price per kWh (in cents) from the past 24 hours (trailing maximum) from your Tibber subscription. This provides the highest price seen in the last 24 hours.",
"usage_tips": "Use this to see the peak price in the past 24 hours and assess price volatility."
},
"leading_price_min_cents": {
"name": "Leading 24h Minimum Price",
"description": "The minimum electricity price for the next 24 hours in cents per kWh",
"long_description": "Shows the minimum price per kWh (in cents) from the next 24 hours (leading minimum) from your Tibber subscription. This provides the lowest price expected in the next 24 hours based on forecast data.",
"usage_tips": "Use this to identify the best price opportunity coming up and schedule energy-intensive tasks accordingly."
},
"leading_price_max_cents": {
"name": "Leading 24h Maximum Price",
"description": "The maximum electricity price for the next 24 hours in cents per kWh",
"long_description": "Shows the maximum price per kWh (in cents) from the next 24 hours (leading maximum) from your Tibber subscription. This provides the highest price expected in the next 24 hours based on forecast data.",
"usage_tips": "Use this to avoid running appliances during upcoming peak price periods."
},
"price_rating": {
"name": "Current Price Rating",
"description": "How the current interval's price compares to historical data",
@ -84,6 +150,30 @@
"HIGH": "High"
}
},
"next_interval_price_rating": {
"name": "Next Price Rating",
"description": "Price rating for the next 15-minute interval",
"long_description": "Shows how the next interval's price compares to the rolling 24-hour average. Helps anticipate if upcoming prices are above or below the recent trend.",
"usage_tips": "Use to make quick decisions about starting activities in the next interval based on relative price position."
},
"previous_interval_price_rating": {
"name": "Previous Price Rating",
"description": "Price rating for the previous 15-minute interval",
"long_description": "Shows how the last interval's price compared to the rolling average. Useful for understanding recent price behavior.",
"usage_tips": "Use for analyzing how price ratings changed over time or validating past consumption decisions."
},
"current_hour_price_rating": {
"name": "Current Hour Price Rating",
"description": "Aggregated price rating for current rolling hour (5 intervals)",
"long_description": "Shows the average rating across 5 intervals (2 before, current, 2 after). Based on the average percentage difference from rolling 24h average, providing a smoothed rating indicator.",
"usage_tips": "Use for stable medium-term price assessment that doesn't overreact to brief price anomalies."
},
"next_hour_price_rating": {
"name": "Next Hour Price Rating",
"description": "Aggregated price rating for next rolling hour (5 intervals ahead)",
"long_description": "Shows the averaged rating for 5 intervals centered one hour ahead. Helps understand if the next hour will generally be above or below average pricing.",
"usage_tips": "Use to decide if you should wait an hour before starting high-consumption activities."
},
"daily_rating": {
"name": "Daily Price Rating",
"description": "How today's prices compare to historical data",

View file

@ -8,6 +8,8 @@ from typing import Any
from homeassistant.util import dt as dt_util
from .const import PRICE_LEVEL_MAPPING, PRICE_LEVEL_NORMAL, PRICE_RATING_NORMAL
_LOGGER = logging.getLogger(__name__)
MINUTES_PER_INTERVAL = 15
@ -68,14 +70,7 @@ def calculate_trailing_average_for_interval(
return None
# Calculate and return the average
average = sum(matching_prices) / len(matching_prices)
_LOGGER.debug(
"Calculated trailing 24h average for interval %s: %.6f from %d prices",
interval_start,
average,
len(matching_prices),
)
return average
return sum(matching_prices) / len(matching_prices)
def calculate_difference_percentage(
@ -184,16 +179,6 @@ def _process_price_interval(
# Calculate rating_level based on difference
rating_level = calculate_rating_level(difference, threshold_low, threshold_high)
price_interval["rating_level"] = rating_level
_LOGGER.debug(
"Set difference and rating_level for %s interval %s: difference=%.2f%%, level=%s (price: %.6f, avg: %.6f)",
day_label,
starts_at,
difference if difference is not None else 0,
rating_level,
float(current_price),
trailing_avg,
)
else:
# Set to None if we couldn't calculate
price_interval["difference"] = None
@ -289,3 +274,72 @@ def find_price_data_for_interval(price_info: Any, target_time: datetime) -> dict
return price_data
return None
def aggregate_price_levels(levels: list[str]) -> str:
"""
Aggregate multiple price levels into a single representative level using median.
Takes a list of price level strings (e.g., "VERY_CHEAP", "NORMAL", "EXPENSIVE")
and returns the median level after sorting by numeric values. This naturally
tends toward "NORMAL" when levels are mixed.
Args:
levels: List of price level strings from intervals
Returns:
The median price level string, or "NORMAL" if input is empty
"""
if not levels:
return PRICE_LEVEL_NORMAL
# Convert levels to numeric values and sort
numeric_values = [PRICE_LEVEL_MAPPING.get(level, 0) for level in levels]
numeric_values.sort()
# Get median (middle value for odd length, lower-middle for even length)
median_idx = len(numeric_values) // 2
median_value = numeric_values[median_idx]
# Convert back to level string
for level, value in PRICE_LEVEL_MAPPING.items():
if value == median_value:
return level
return PRICE_LEVEL_NORMAL
def aggregate_price_rating(differences: list[float], threshold_low: float, threshold_high: float) -> tuple[str, float]:
"""
Aggregate multiple price differences into a single rating level.
Calculates the average difference percentage across multiple intervals
and applies thresholds to determine the overall rating level.
Args:
differences: List of difference percentages from intervals
threshold_low: The low threshold percentage for LOW rating
threshold_high: The high threshold percentage for HIGH rating
Returns:
Tuple of (rating_level, average_difference)
rating_level: "LOW", "NORMAL", or "HIGH"
average_difference: The averaged difference percentage
"""
if not differences:
return PRICE_RATING_NORMAL, 0.0
# Filter out None values
valid_differences = [d for d in differences if d is not None]
if not valid_differences:
return PRICE_RATING_NORMAL, 0.0
# Calculate average difference
avg_difference = sum(valid_differences) / len(valid_differences)
# Apply thresholds
rating_level = calculate_rating_level(avg_difference, threshold_low, threshold_high)
return rating_level or PRICE_RATING_NORMAL, avg_difference

View file

@ -19,9 +19,23 @@ from homeassistant.const import (
)
from homeassistant.util import dt as dt_util
from .average_utils import (
calculate_current_leading_avg,
calculate_current_leading_max,
calculate_current_leading_min,
calculate_current_rolling_5interval_avg,
calculate_current_trailing_avg,
calculate_current_trailing_max,
calculate_current_trailing_min,
calculate_next_hour_rolling_5interval_avg,
)
from .const import (
CONF_EXTENDED_DESCRIPTIONS,
CONF_PRICE_RATING_THRESHOLD_HIGH,
CONF_PRICE_RATING_THRESHOLD_LOW,
DEFAULT_EXTENDED_DESCRIPTIONS,
DEFAULT_PRICE_RATING_THRESHOLD_HIGH,
DEFAULT_PRICE_RATING_THRESHOLD_LOW,
DOMAIN,
PRICE_LEVEL_MAPPING,
PRICE_RATING_MAPPING,
@ -30,7 +44,12 @@ from .const import (
get_price_level_translation,
)
from .entity import TibberPricesEntity
from .price_utils import find_price_data_for_interval
from .price_utils import (
MINUTES_PER_INTERVAL,
aggregate_price_levels,
aggregate_price_rating,
find_price_data_for_interval,
)
if TYPE_CHECKING:
from collections.abc import Callable
@ -46,7 +65,6 @@ PRICE_UNIT_MINOR = "ct/" + UnitOfPower.KILO_WATT + UnitOfTime.HOURS
HOURS_IN_DAY = 24
LAST_HOUR_OF_DAY = 23
INTERVALS_PER_HOUR = 4 # 15-minute intervals
MINUTES_PER_INTERVAL = 15
MAX_FORECAST_INTERVALS = 8 # Show up to 8 future intervals (2 hours with 15-min intervals)
# Main price sensors that users will typically use in automations
@ -58,36 +76,44 @@ PRICE_SENSORS = (
icon="mdi:currency-eur",
device_class=SensorDeviceClass.MONETARY,
native_unit_of_measurement=PRICE_UNIT_MINOR,
suggested_display_precision=1,
),
SensorEntityDescription(
key="current_price_eur",
translation_key="current_price",
name="Current Electricity Price",
icon="mdi:currency-eur",
device_class=SensorDeviceClass.MONETARY,
native_unit_of_measurement=PRICE_UNIT,
entity_registry_enabled_default=False,
suggested_display_precision=2,
),
SensorEntityDescription(
key="next_interval_price",
translation_key="next_interval_price_cents",
name="Next Interval Electricity Price",
name="Next Price",
icon="mdi:currency-eur-off",
device_class=SensorDeviceClass.MONETARY,
native_unit_of_measurement=PRICE_UNIT_MINOR,
suggested_display_precision=2,
),
SensorEntityDescription(
key="previous_interval_price",
translation_key="previous_interval_price_cents",
name="Previous Electricity Price",
icon="mdi:currency-eur-off",
device_class=SensorDeviceClass.MONETARY,
native_unit_of_measurement=PRICE_UNIT_MINOR,
entity_registry_enabled_default=False,
suggested_display_precision=2,
),
SensorEntityDescription(
key="current_hour_average",
translation_key="current_hour_average_cents",
name="Current Hour Average Price",
icon="mdi:currency-eur",
device_class=SensorDeviceClass.MONETARY,
native_unit_of_measurement=PRICE_UNIT_MINOR,
suggested_display_precision=1,
),
SensorEntityDescription(
key="next_interval_price_eur",
translation_key="next_interval_price",
name="Next Interval Electricity Price",
icon="mdi:currency-eur-off",
key="next_hour_average",
translation_key="next_hour_average_cents",
name="Next Hour Average Price",
icon="mdi:cash",
device_class=SensorDeviceClass.MONETARY,
native_unit_of_measurement=PRICE_UNIT,
entity_registry_enabled_default=False,
suggested_display_precision=2,
native_unit_of_measurement=PRICE_UNIT_MINOR,
suggested_display_precision=1,
),
SensorEntityDescription(
key="price_level",
@ -95,6 +121,31 @@ PRICE_SENSORS = (
name="Current Price Level",
icon="mdi:meter-electric",
),
SensorEntityDescription(
key="next_interval_price_level",
translation_key="next_interval_price_level",
name="Next Price Level",
icon="mdi:meter-electric",
),
SensorEntityDescription(
key="previous_interval_price_level",
translation_key="previous_interval_price_level",
name="Previous Price Level",
icon="mdi:meter-electric",
entity_registry_enabled_default=False,
),
SensorEntityDescription(
key="current_hour_price_level",
translation_key="current_hour_price_level",
name="Current Hour Price Level",
icon="mdi:meter-electric",
),
SensorEntityDescription(
key="next_hour_price_level",
translation_key="next_hour_price_level",
name="Next Hour Price Level",
icon="mdi:meter-electric",
),
)
# Statistical price sensors
@ -103,58 +154,112 @@ STATISTICS_SENSORS = (
key="lowest_price_today",
translation_key="lowest_price_today_cents",
name="Today's Lowest Price",
icon="mdi:currency-eur",
icon="mdi:arrow-collapse-down",
device_class=SensorDeviceClass.MONETARY,
native_unit_of_measurement=PRICE_UNIT_MINOR,
suggested_display_precision=1,
),
SensorEntityDescription(
key="lowest_price_today_eur",
translation_key="lowest_price_today",
name="Today's Lowest Price",
icon="mdi:currency-eur",
device_class=SensorDeviceClass.MONETARY,
native_unit_of_measurement=PRICE_UNIT,
entity_registry_enabled_default=False,
suggested_display_precision=2,
),
SensorEntityDescription(
key="highest_price_today",
translation_key="highest_price_today_cents",
name="Today's Highest Price",
icon="mdi:currency-eur",
icon="mdi:arrow-collapse-up",
device_class=SensorDeviceClass.MONETARY,
native_unit_of_measurement=PRICE_UNIT_MINOR,
suggested_display_precision=1,
),
SensorEntityDescription(
key="highest_price_today_eur",
translation_key="highest_price_today",
name="Today's Highest Price",
icon="mdi:currency-eur",
device_class=SensorDeviceClass.MONETARY,
native_unit_of_measurement=PRICE_UNIT,
entity_registry_enabled_default=False,
suggested_display_precision=2,
),
SensorEntityDescription(
key="average_price_today",
translation_key="average_price_today_cents",
name="Today's Average Price",
icon="mdi:currency-eur",
icon="mdi:chart-line",
device_class=SensorDeviceClass.MONETARY,
native_unit_of_measurement=PRICE_UNIT_MINOR,
suggested_display_precision=1,
),
SensorEntityDescription(
key="average_price_today_eur",
translation_key="average_price_today",
name="Today's Average Price",
icon="mdi:currency-eur",
key="lowest_price_tomorrow",
translation_key="lowest_price_tomorrow_cents",
name="Tomorrow's Lowest Price",
icon="mdi:arrow-collapse-down",
device_class=SensorDeviceClass.MONETARY,
native_unit_of_measurement=PRICE_UNIT,
native_unit_of_measurement=PRICE_UNIT_MINOR,
suggested_display_precision=1,
),
SensorEntityDescription(
key="highest_price_tomorrow",
translation_key="highest_price_tomorrow_cents",
name="Tomorrow's Highest Price",
icon="mdi:arrow-collapse-up",
device_class=SensorDeviceClass.MONETARY,
native_unit_of_measurement=PRICE_UNIT_MINOR,
suggested_display_precision=1,
),
SensorEntityDescription(
key="average_price_tomorrow",
translation_key="average_price_tomorrow_cents",
name="Tomorrow's Average Price",
icon="mdi:chart-line",
device_class=SensorDeviceClass.MONETARY,
native_unit_of_measurement=PRICE_UNIT_MINOR,
suggested_display_precision=1,
),
SensorEntityDescription(
key="trailing_price_average",
translation_key="trailing_price_average_cents",
name="Trailing 24h Average Price",
icon="mdi:chart-line",
device_class=SensorDeviceClass.MONETARY,
native_unit_of_measurement=PRICE_UNIT_MINOR,
entity_registry_enabled_default=False,
suggested_display_precision=2,
suggested_display_precision=1,
),
SensorEntityDescription(
key="leading_price_average",
translation_key="leading_price_average_cents",
name="Leading 24h Average Price",
icon="mdi:chart-line-variant",
device_class=SensorDeviceClass.MONETARY,
native_unit_of_measurement=PRICE_UNIT_MINOR,
suggested_display_precision=1,
),
SensorEntityDescription(
key="trailing_price_min",
translation_key="trailing_price_min_cents",
name="Trailing 24h Minimum Price",
icon="mdi:arrow-collapse-down",
device_class=SensorDeviceClass.MONETARY,
native_unit_of_measurement=PRICE_UNIT_MINOR,
entity_registry_enabled_default=False,
suggested_display_precision=1,
),
SensorEntityDescription(
key="trailing_price_max",
translation_key="trailing_price_max_cents",
name="Trailing 24h Maximum Price",
icon="mdi:arrow-collapse-up",
device_class=SensorDeviceClass.MONETARY,
native_unit_of_measurement=PRICE_UNIT_MINOR,
entity_registry_enabled_default=False,
suggested_display_precision=1,
),
SensorEntityDescription(
key="leading_price_min",
translation_key="leading_price_min_cents",
name="Leading 24h Minimum Price",
icon="mdi:arrow-collapse-down",
device_class=SensorDeviceClass.MONETARY,
native_unit_of_measurement=PRICE_UNIT_MINOR,
suggested_display_precision=1,
),
SensorEntityDescription(
key="leading_price_max",
translation_key="leading_price_max_cents",
name="Leading 24h Maximum Price",
icon="mdi:arrow-collapse-up",
device_class=SensorDeviceClass.MONETARY,
native_unit_of_measurement=PRICE_UNIT_MINOR,
suggested_display_precision=1,
),
)
@ -166,6 +271,31 @@ RATING_SENSORS = (
name="Current Price Rating",
icon="mdi:clock-outline",
),
SensorEntityDescription(
key="next_interval_price_rating",
translation_key="next_interval_price_rating",
name="Next Price Rating",
icon="mdi:clock-outline",
),
SensorEntityDescription(
key="previous_interval_price_rating",
translation_key="previous_interval_price_rating",
name="Previous Price Rating",
icon="mdi:clock-outline",
entity_registry_enabled_default=False,
),
SensorEntityDescription(
key="current_hour_price_rating",
translation_key="current_hour_price_rating",
name="Current Hour Price Rating",
icon="mdi:clock-outline",
),
SensorEntityDescription(
key="next_hour_price_rating",
translation_key="next_hour_price_rating",
name="Next Hour Price Rating",
icon="mdi:clock-outline",
),
)
# Diagnostic sensors for data availability
@ -232,30 +362,84 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
# Map sensor keys to their handler methods
handlers = {
# Price level
# Price level sensors
"price_level": self._get_price_level_value,
"next_interval_price_level": lambda: self._get_interval_level_value(interval_offset=1),
"previous_interval_price_level": lambda: self._get_interval_level_value(interval_offset=-1),
"current_hour_price_level": lambda: self._get_rolling_hour_level_value(hour_offset=0),
"next_hour_price_level": lambda: self._get_rolling_hour_level_value(hour_offset=1),
# Price sensors
"current_price": lambda: self._get_interval_price_value(interval_offset=0, in_euro=False),
"current_price_eur": lambda: self._get_interval_price_value(interval_offset=0, in_euro=True),
"next_interval_price": lambda: self._get_interval_price_value(interval_offset=1, in_euro=False),
"next_interval_price_eur": lambda: self._get_interval_price_value(interval_offset=1, in_euro=True),
"previous_interval_price": lambda: self._get_interval_price_value(interval_offset=-1, in_euro=False),
# Rolling hour average (5 intervals: 2 before + current + 2 after)
"current_hour_average": lambda: self._get_rolling_hour_average_value(
in_euro=False, decimals=2, hour_offset=0
),
"next_hour_average": lambda: self._get_rolling_hour_average_value(in_euro=False, decimals=2, hour_offset=1),
# Statistics sensors
"lowest_price_today": lambda: self._get_statistics_value(stat_func=min, in_euro=False, decimals=2),
"lowest_price_today_eur": lambda: self._get_statistics_value(stat_func=min, in_euro=True, decimals=4),
"highest_price_today": lambda: self._get_statistics_value(stat_func=max, in_euro=False, decimals=2),
"highest_price_today_eur": lambda: self._get_statistics_value(stat_func=max, in_euro=True, decimals=4),
"average_price_today": lambda: self._get_statistics_value(
stat_func=lambda prices: sum(prices) / len(prices),
in_euro=False,
decimals=2,
),
"average_price_today_eur": lambda: self._get_statistics_value(
# Tomorrow statistics sensors
"lowest_price_tomorrow": lambda: self._get_statistics_value(
stat_func=min, in_euro=False, decimals=2, day="tomorrow"
),
"highest_price_tomorrow": lambda: self._get_statistics_value(
stat_func=max, in_euro=False, decimals=2, day="tomorrow"
),
"average_price_tomorrow": lambda: self._get_statistics_value(
stat_func=lambda prices: sum(prices) / len(prices),
in_euro=True,
decimals=4,
in_euro=False,
decimals=2,
day="tomorrow",
),
# Trailing and leading average sensors
"trailing_price_average": lambda: self._get_average_value(
average_type="trailing",
in_euro=False,
decimals=2,
),
"leading_price_average": lambda: self._get_average_value(
average_type="leading",
in_euro=False,
decimals=2,
),
# Trailing and leading min/max sensors
"trailing_price_min": lambda: self._get_minmax_value(
stat_type="trailing",
func_type="min",
in_euro=False,
decimals=2,
),
"trailing_price_max": lambda: self._get_minmax_value(
stat_type="trailing",
func_type="max",
in_euro=False,
decimals=2,
),
"leading_price_min": lambda: self._get_minmax_value(
stat_type="leading",
func_type="min",
in_euro=False,
decimals=2,
),
"leading_price_max": lambda: self._get_minmax_value(
stat_type="leading",
func_type="max",
in_euro=False,
decimals=2,
),
# Rating sensors
"price_rating": lambda: self._get_rating_value(rating_type="current"),
"next_interval_price_rating": lambda: self._get_interval_rating_value(interval_offset=1),
"previous_interval_price_rating": lambda: self._get_interval_rating_value(interval_offset=-1),
"current_hour_price_rating": lambda: self._get_rolling_hour_rating_value(hour_offset=0),
"next_hour_price_rating": lambda: self._get_rolling_hour_rating_value(hour_offset=1),
# Diagnostic sensors
"data_timestamp": self._get_data_timestamp,
# Price forecast sensor
@ -287,6 +471,106 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
return fallback
return level
def _get_interval_level_value(self, *, interval_offset: int) -> str | None:
"""Get price level for an interval with offset (e.g., next or previous interval)."""
if not self.coordinator.data:
return None
price_info = self.coordinator.data.get("priceInfo", {})
now = dt_util.now()
target_time = now + timedelta(minutes=MINUTES_PER_INTERVAL * interval_offset)
interval_data = find_price_data_for_interval(price_info, target_time)
if not interval_data or "level" not in interval_data:
return None
level = interval_data["level"]
# Translate the level
if self.hass:
language = self.hass.config.language or "en"
translated = get_price_level_translation(level, language)
if translated:
return translated
if language != "en":
fallback = get_price_level_translation(level, "en")
if fallback:
return fallback
return level
def _get_rolling_hour_level_value(self, *, hour_offset: int) -> str | None:
"""Get aggregated price level for a 5-interval rolling window."""
if not self.coordinator.data:
return None
price_info = self.coordinator.data.get("priceInfo", {})
yesterday_prices = price_info.get("yesterday", [])
today_prices = price_info.get("today", [])
tomorrow_prices = price_info.get("tomorrow", [])
all_prices = yesterday_prices + today_prices + tomorrow_prices
if not all_prices:
return None
center_idx = self._find_rolling_hour_center_index(all_prices, hour_offset)
if center_idx is None:
return None
levels = self._collect_rolling_window_levels(all_prices, center_idx)
if not levels:
return None
aggregated_level = aggregate_price_levels(levels)
return self._translate_level(aggregated_level)
def _find_rolling_hour_center_index(self, all_prices: list, hour_offset: int) -> int | None:
"""Find the center index for the rolling hour window."""
now = dt_util.now()
current_idx = None
for idx, price_data in enumerate(all_prices):
starts_at = dt_util.parse_datetime(price_data["startsAt"])
if starts_at is None:
continue
starts_at = dt_util.as_local(starts_at)
interval_end = starts_at + timedelta(minutes=15)
if starts_at <= now < interval_end:
current_idx = idx
break
if current_idx is None:
return None
return current_idx + (hour_offset * 4)
def _collect_rolling_window_levels(self, all_prices: list, center_idx: int) -> list:
"""Collect levels from 2 intervals before to 2 intervals after."""
levels = []
for offset in range(-2, 3): # -2, -1, 0, 1, 2
idx = center_idx + offset
if 0 <= idx < len(all_prices):
level = all_prices[idx].get("level")
if level is not None:
levels.append(level)
return levels
def _translate_level(self, level: str) -> str:
"""Translate the level to the user's language."""
if not self.hass:
return level
language = self.hass.config.language or "en"
translated = get_price_level_translation(level, language)
if translated:
return translated
if language != "en":
fallback = get_price_level_translation(level, "en")
if fallback:
return fallback
return level
def _get_price_value(self, price: float, *, in_euro: bool) -> float:
"""Convert price based on unit."""
return price if in_euro else round((price * 100), 2)
@ -372,10 +656,17 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
stat_func: Callable[[list[float]], float],
in_euro: bool,
decimals: int | None = None,
day: str = "today",
) -> float | None:
"""
Handle statistics sensor values using the provided statistical function.
Args:
stat_func: The statistical function to apply (min, max, avg, etc.)
in_euro: Whether to return the value in euros (True) or cents (False)
decimals: Number of decimal places to round to
day: Which day to calculate for - "today" or "tomorrow"
Returns:
The calculated value for the statistics sensor, or None if unavailable.
@ -385,11 +676,13 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
price_info = self.coordinator.data.get("priceInfo", {})
# Get local midnight boundaries
# Get local midnight boundaries based on the requested day
local_midnight = dt_util.as_local(dt_util.start_of_local_day(dt_util.now()))
local_midnight_tomorrow = local_midnight + timedelta(days=1)
if day == "tomorrow":
local_midnight = local_midnight + timedelta(days=1)
local_midnight_next_day = local_midnight + timedelta(days=1)
# Collect all prices and their intervals from both today and tomorrow data that fall within local today
# Collect all prices and their intervals from both today and tomorrow data that fall within the target day
price_intervals = []
for day_key in ["today", "tomorrow"]:
for price_data in price_info.get(day_key, []):
@ -404,8 +697,8 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
# Convert to local timezone for comparison
starts_at = dt_util.as_local(starts_at)
# Include price if it starts within today's local date boundaries
if local_midnight <= starts_at < local_midnight_tomorrow:
# Include price if it starts within the target day's local date boundaries
if local_midnight <= starts_at < local_midnight_next_day:
total_price = price_data.get("total")
if total_price is not None:
price_intervals.append(
@ -434,6 +727,121 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
result = round(result, decimals)
return result
def _get_average_value(
self,
*,
average_type: str,
in_euro: bool,
decimals: int | None = None,
) -> float | None:
"""
Get trailing or leading 24-hour average price.
Args:
average_type: Either "trailing" or "leading"
in_euro: If True, return value in euros; if False, return in cents
decimals: Number of decimal places to round to, or None for no rounding
Returns:
The calculated average value, or None if unavailable
"""
if average_type == "trailing":
value = calculate_current_trailing_avg(self.coordinator.data)
elif average_type == "leading":
value = calculate_current_leading_avg(self.coordinator.data)
else:
return None
if value is None:
return None
result = self._get_price_value(value, in_euro=in_euro)
if decimals is not None:
result = round(result, decimals)
return result
def _get_rolling_hour_average_value(
self,
*,
in_euro: bool,
decimals: int | None = None,
hour_offset: int = 0,
) -> float | None:
"""
Get rolling 5-interval average (2 previous + current + 2 next).
This provides a smoothed "hour price" centered around a specific hour.
With hour_offset=0, it's centered on the current interval.
With hour_offset=1, it's centered on the interval 1 hour ahead.
Args:
in_euro: If True, return value in euros; if False, return in cents
decimals: Number of decimal places to round to, or None for no rounding
hour_offset: Number of hours to shift forward (0=current, 1=next hour)
Returns:
The calculated rolling average value, or None if unavailable
"""
if hour_offset == 0:
value = calculate_current_rolling_5interval_avg(self.coordinator.data)
elif hour_offset == 1:
value = calculate_next_hour_rolling_5interval_avg(self.coordinator.data)
else:
return None
if value is None:
return None
result = self._get_price_value(value, in_euro=in_euro)
if decimals is not None:
result = round(result, decimals)
return result
def _get_minmax_value(
self,
*,
stat_type: str,
func_type: str,
in_euro: bool,
decimals: int | None = None,
) -> float | None:
"""
Get trailing or leading 24-hour minimum or maximum price.
Args:
stat_type: Either "trailing" or "leading"
func_type: Either "min" or "max"
in_euro: If True, return value in euros; if False, return in cents
decimals: Number of decimal places to round to, or None for no rounding
Returns:
The calculated min/max value, or None if unavailable
"""
if stat_type == "trailing" and func_type == "min":
value = calculate_current_trailing_min(self.coordinator.data)
elif stat_type == "trailing" and func_type == "max":
value = calculate_current_trailing_max(self.coordinator.data)
elif stat_type == "leading" and func_type == "min":
value = calculate_current_leading_min(self.coordinator.data)
elif stat_type == "leading" and func_type == "max":
value = calculate_current_leading_max(self.coordinator.data)
else:
return None
if value is None:
return None
result = self._get_price_value(value, in_euro=in_euro)
if decimals is not None:
result = round(result, decimals)
return result
def _translate_rating_level(self, level: str) -> str:
"""Translate the rating level using custom translations, falling back to English or the raw value."""
if not self.hass or not level:
@ -491,6 +899,86 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
self._last_rating_level = None
return None
def _get_interval_rating_value(self, *, interval_offset: int) -> str | None:
"""Get price rating for an interval with offset (e.g., next or previous interval)."""
if not self.coordinator.data:
return None
price_info = self.coordinator.data.get("priceInfo", {})
now = dt_util.now()
target_time = now + timedelta(minutes=MINUTES_PER_INTERVAL * interval_offset)
interval_data = find_price_data_for_interval(price_info, target_time)
if not interval_data:
return None
rating_level = interval_data.get("rating_level")
if rating_level is not None:
return self._translate_rating_level(rating_level)
return None
def _get_rolling_hour_rating_value(self, *, hour_offset: int) -> str | None:
"""Get aggregated price rating for a 5-interval rolling window."""
if not self.coordinator.data:
return None
price_info = self.coordinator.data.get("priceInfo", {})
yesterday_prices = price_info.get("yesterday", [])
today_prices = price_info.get("today", [])
tomorrow_prices = price_info.get("tomorrow", [])
all_prices = yesterday_prices + today_prices + tomorrow_prices
if not all_prices:
return None
now = dt_util.now()
# Find the current interval
current_idx = None
for idx, price_data in enumerate(all_prices):
starts_at = dt_util.parse_datetime(price_data["startsAt"])
if starts_at is None:
continue
starts_at = dt_util.as_local(starts_at)
interval_end = starts_at + timedelta(minutes=15)
if starts_at <= now < interval_end:
current_idx = idx
break
if current_idx is None:
return None
# Shift by hour_offset * 4 intervals (4 intervals = 1 hour)
center_idx = current_idx + (hour_offset * 4)
# Collect differences from 2 intervals before to 2 intervals after (5 total)
differences = []
for offset in range(-2, 3): # -2, -1, 0, 1, 2
idx = center_idx + offset
if 0 <= idx < len(all_prices):
difference = all_prices[idx].get("difference")
if difference is not None:
differences.append(float(difference))
if not differences:
return None
# Get thresholds from config
threshold_low = self.coordinator.config_entry.options.get(
CONF_PRICE_RATING_THRESHOLD_LOW,
DEFAULT_PRICE_RATING_THRESHOLD_LOW,
)
threshold_high = self.coordinator.config_entry.options.get(
CONF_PRICE_RATING_THRESHOLD_HIGH,
DEFAULT_PRICE_RATING_THRESHOLD_HIGH,
)
# Aggregate using average difference
aggregated_rating, _avg_diff = aggregate_price_rating(differences, threshold_low, threshold_high)
return self._translate_rating_level(aggregated_rating)
def _get_data_timestamp(self) -> datetime | None:
"""Get the latest data timestamp."""
if not self.coordinator.data:
@ -775,13 +1263,31 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
# Group sensors by type and delegate to specific handlers
if key in [
"current_price",
"current_price_eur",
"price_level",
"next_interval_price",
"next_interval_price_eur",
"previous_interval_price",
"current_hour_average",
"next_hour_average",
"next_interval_price_level",
"previous_interval_price_level",
"current_hour_price_level",
"next_hour_price_level",
"next_interval_price_rating",
"previous_interval_price_rating",
"current_hour_price_rating",
"next_hour_price_rating",
]:
self._add_current_price_attributes(attributes)
elif any(pattern in key for pattern in ["_price_today", "rating", "data_timestamp"]):
elif key in [
"trailing_price_average",
"leading_price_average",
"trailing_price_min",
"trailing_price_max",
"leading_price_min",
"leading_price_max",
]:
self._add_average_price_attributes(attributes)
elif any(pattern in key for pattern in ["_price_today", "_price_tomorrow", "rating", "data_timestamp"]):
self._add_statistics_attributes(attributes)
elif key == "price_forecast":
self._add_price_forecast_attributes(attributes)
@ -801,23 +1307,60 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
def _add_current_price_attributes(self, attributes: dict) -> None:
"""Add attributes for current price sensors."""
key = self.entity_description.key
price_info = self.coordinator.data.get("priceInfo", {}) if self.coordinator.data else {}
now = dt_util.now()
# Determine which interval to use based on sensor type
next_interval_sensors = [
"next_interval_price",
"next_interval_price_level",
"next_interval_price_rating",
]
previous_interval_sensors = [
"previous_interval_price",
"previous_interval_price_level",
"previous_interval_price_rating",
]
next_hour_sensors = [
"next_hour_average",
"next_hour_price_level",
"next_hour_price_rating",
]
current_hour_sensors = [
"current_hour_average",
"current_hour_price_level",
"current_hour_price_rating",
]
if key in next_interval_sensors:
target_time = now + timedelta(minutes=MINUTES_PER_INTERVAL)
interval_data = find_price_data_for_interval(price_info, target_time)
attributes["timestamp"] = interval_data["startsAt"] if interval_data else None
elif key in previous_interval_sensors:
target_time = now - timedelta(minutes=MINUTES_PER_INTERVAL)
interval_data = find_price_data_for_interval(price_info, target_time)
attributes["timestamp"] = interval_data["startsAt"] if interval_data else None
elif key in next_hour_sensors:
# For next hour sensors, show timestamp 1 hour ahead
target_time = now + timedelta(hours=1)
interval_data = find_price_data_for_interval(price_info, target_time)
attributes["timestamp"] = interval_data["startsAt"] if interval_data else None
elif key in current_hour_sensors:
# For current hour sensors, use current interval timestamp
current_interval_data = self._get_current_interval_data()
attributes["timestamp"] = current_interval_data["startsAt"] if current_interval_data else None
else:
# Default: use current interval timestamp
current_interval_data = self._get_current_interval_data()
attributes["timestamp"] = current_interval_data["startsAt"] if current_interval_data else None
# Add price level info for the price level sensor
if self.entity_description.key == "price_level" and current_interval_data and "level" in current_interval_data:
# Add price level info for price level sensors
if key == "price_level":
current_interval_data = self._get_current_interval_data()
if current_interval_data and "level" in current_interval_data:
self._add_price_level_attributes(attributes, current_interval_data["level"])
if self.entity_description.key in [
"next_interval_price",
"next_interval_price_eur",
]:
price_info = self.coordinator.data.get("priceInfo", {})
now = dt_util.now()
next_interval_time = now + timedelta(minutes=MINUTES_PER_INTERVAL)
next_interval_data = find_price_data_for_interval(price_info, next_interval_time)
attributes["timestamp"] = next_interval_data["startsAt"] if next_interval_data else None
def _add_price_level_attributes(self, attributes: dict, level: str) -> None:
"""
Add price level specific attributes.
@ -866,21 +1409,64 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
attributes["level_value"] = PRICE_RATING_MAPPING.get(self._last_rating_level, self._last_rating_level)
elif key in [
"lowest_price_today",
"lowest_price_today_eur",
"highest_price_today",
"highest_price_today_eur",
"lowest_price_tomorrow",
"highest_price_tomorrow",
]:
# Use the timestamp from the interval that has the extreme price (already stored during value calculation)
if hasattr(self, "_last_extreme_interval") and self._last_extreme_interval:
attributes["timestamp"] = self._last_extreme_interval.get("startsAt")
else:
# Fallback: use the first timestamp of today
attributes["timestamp"] = price_info.get("today", [{}])[0].get("startsAt")
# Fallback: use the first timestamp of the appropriate day
day_key = "tomorrow" if "tomorrow" in key else "today"
attributes["timestamp"] = price_info.get(day_key, [{}])[0].get("startsAt")
else:
# Fallback: use the first timestamp of today
first_timestamp = price_info.get("today", [{}])[0].get("startsAt")
# Fallback: use the first timestamp of the appropriate day
day_key = "tomorrow" if "tomorrow" in key else "today"
first_timestamp = price_info.get(day_key, [{}])[0].get("startsAt")
attributes["timestamp"] = first_timestamp
def _add_average_price_attributes(self, attributes: dict) -> None:
"""Add attributes for trailing and leading average price sensors."""
key = self.entity_description.key
now = dt_util.now()
# Determine if this is trailing or leading
is_trailing = "trailing" in key
# Get all price intervals
price_info = self.coordinator.data.get("priceInfo", {})
yesterday_prices = price_info.get("yesterday", [])
today_prices = price_info.get("today", [])
tomorrow_prices = price_info.get("tomorrow", [])
all_prices = yesterday_prices + today_prices + tomorrow_prices
if not all_prices:
return
# Calculate the time window
if is_trailing:
window_start = now - timedelta(hours=24)
window_end = now
else:
window_start = now
window_end = now + timedelta(hours=24)
# Find all intervals in the window and get first/last timestamps
intervals_in_window = []
for price_data in all_prices:
starts_at = dt_util.parse_datetime(price_data["startsAt"])
if starts_at is None:
continue
starts_at = dt_util.as_local(starts_at)
if window_start <= starts_at < window_end:
intervals_in_window.append(price_data)
# Add timestamp attribute (first interval in the window)
if intervals_in_window:
attributes["timestamp"] = intervals_in_window[0].get("startsAt")
attributes["interval_count"] = len(intervals_in_window)
async def async_update(self) -> None:
"""Force a refresh when homeassistant.update_entity is called."""
await self.coordinator.async_request_refresh()

View file

@ -18,6 +18,7 @@ from .api import (
TibberPricesApiClientCommunicationError,
TibberPricesApiClientError,
)
from .average_utils import calculate_leading_24h_avg, calculate_trailing_24h_avg
from .const import (
DOMAIN,
PRICE_LEVEL_CHEAP,
@ -128,6 +129,9 @@ async def _get_price(call: ServiceCall) -> dict[str, Any]:
# Add end_time to intervals
_annotate_end_times(prices_transformed, price_info_data, day_key)
# Enrich intervals with trailing and leading averages
_enrich_intervals_with_averages(prices_transformed, price_info_data)
# Clean up temp fields from all intervals
for interval in prices_transformed:
if "start_dt" in interval:
@ -394,6 +398,37 @@ def _annotate_end_times(merged: list[dict], price_info_by_day: dict, day: str) -
interval["end_time"] = None
def _enrich_intervals_with_averages(intervals: list[dict], price_info_by_day: dict) -> None:
"""Enrich intervals with trailing and leading 24-hour average prices."""
# Collect all price data from yesterday, today, and tomorrow
all_prices = []
for day in ["yesterday", "today", "tomorrow"]:
all_prices.extend(price_info_by_day.get(day, []))
if not all_prices:
return
# Enrich each interval with trailing and leading averages
for interval in intervals:
start_dt = interval.get("start_dt")
if not start_dt:
continue
# Ensure start_dt is timezone-aware
if start_dt.tzinfo is None:
start_dt = dt_util.as_local(start_dt)
# Calculate trailing and leading averages for this interval
trailing_avg = calculate_trailing_24h_avg(all_prices, start_dt)
leading_avg = calculate_leading_24h_avg(all_prices, start_dt)
# Add to interval
interval["trailing_price_average"] = round(trailing_avg, 4)
interval["trailing_price_average_minor"] = round(trailing_avg * 100, 2)
interval["leading_price_average"] = round(leading_avg, 4)
interval["leading_price_average_minor"] = round(leading_avg * 100, 2)
def _get_price_stats(merged: list[dict]) -> PriceStats:
"""Calculate average, min, and max price from merged data."""
if merged:

View file

@ -99,42 +99,87 @@
},
"entity": {
"sensor": {
"current_price": {
"name": "Aktueller Strompreis"
},
"current_price_cents": {
"name": "Aktueller Strompreis"
},
"next_interval_price": {
"name": "Strompreis nächstes Intervall"
},
"next_interval_price_cents": {
"name": "Strompreis nächstes Intervall"
"name": "Nächster Preis"
},
"previous_interval_price_cents": {
"name": "Vorheriger Preis"
},
"current_hour_average_cents": {
"name": "Aktueller Stunden-Durchschnittspreis"
},
"next_hour_average_cents": {
"name": "Nächster Stunden-Durchschnittspreis"
},
"price_level": {
"name": "Aktuelles Preisniveau"
},
"lowest_price_today": {
"name": "Niedrigster Preis heute"
"next_interval_price_level": {
"name": "Nächstes Preisniveau"
},
"previous_interval_price_level": {
"name": "Vorheriges Preisniveau"
},
"current_hour_price_level": {
"name": "Aktuelles Stunden-Preisniveau"
},
"next_hour_price_level": {
"name": "Nächstes Stunden-Preisniveau"
},
"lowest_price_today_cents": {
"name": "Niedrigster Preis heute"
},
"highest_price_today": {
"name": "Höchster Preis heute"
"name": "Mindestpreis heute"
},
"highest_price_today_cents": {
"name": "Höchster Preis heute"
},
"average_price_today": {
"name": "Durchschnittspreis heute"
"name": "Höchstpreis heute"
},
"average_price_today_cents": {
"name": "Durchschnittspreis heute"
},
"lowest_price_tomorrow_cents": {
"name": "Mindestpreis morgen"
},
"highest_price_tomorrow_cents": {
"name": "Höchstpreis morgen"
},
"average_price_tomorrow_cents": {
"name": "Durchschnittspreis morgen"
},
"trailing_price_average_cents": {
"name": "Nachlaufender 24h-Durchschnittspreis"
},
"leading_price_average_cents": {
"name": "Vorlaufender 24h-Durchschnittspreis"
},
"trailing_price_min_cents": {
"name": "Nachlaufender 24h-Mindestpreis"
},
"trailing_price_max_cents": {
"name": "Nachlaufender 24h-Höchstpreis"
},
"leading_price_min_cents": {
"name": "Vorlaufender 24h-Mindestpreis"
},
"leading_price_max_cents": {
"name": "Vorlaufender 24h-Höchstpreis"
},
"price_rating": {
"name": "Aktuelle Preisbewertung"
},
"next_interval_price_rating": {
"name": "Nächste Preisbewertung"
},
"previous_interval_price_rating": {
"name": "Vorherige Preisbewertung"
},
"current_hour_price_rating": {
"name": "Aktuelle Stunden-Preisbewertung"
},
"next_hour_price_rating": {
"name": "Nächste Stunden-Preisbewertung"
},
"daily_rating": {
"name": "Tägliche Preisbewertung"
},

View file

@ -95,42 +95,87 @@
},
"entity": {
"sensor": {
"current_price": {
"name": "Current Electricity Price"
},
"current_price_cents": {
"name": "Current Electricity Price"
},
"next_interval_price": {
"name": "Next Interval Electricity Price"
},
"next_interval_price_cents": {
"name": "Next Interval Electricity Price"
"name": "Next Price"
},
"previous_interval_price_cents": {
"name": "Previous Electricity Price"
},
"current_hour_average_cents": {
"name": "Current Hour Average Price"
},
"next_hour_average_cents": {
"name": "Next Hour Average Price"
},
"price_level": {
"name": "Current Price Level"
},
"lowest_price_today": {
"name": "Today's Lowest Price"
"next_interval_price_level": {
"name": "Next Price Level"
},
"previous_interval_price_level": {
"name": "Previous Price Level"
},
"current_hour_price_level": {
"name": "Current Hour Price Level"
},
"next_hour_price_level": {
"name": "Next Hour Price Level"
},
"lowest_price_today_cents": {
"name": "Today's Lowest Price"
},
"highest_price_today": {
"name": "Today's Highest Price"
},
"highest_price_today_cents": {
"name": "Today's Highest Price"
},
"average_price_today": {
"name": "Today's Average Price"
},
"average_price_today_cents": {
"name": "Today's Average Price"
},
"lowest_price_tomorrow_cents": {
"name": "Tomorrow's Lowest Price"
},
"highest_price_tomorrow_cents": {
"name": "Tomorrow's Highest Price"
},
"average_price_tomorrow_cents": {
"name": "Tomorrow's Average Price"
},
"trailing_price_average_cents": {
"name": "Trailing 24h Average Price"
},
"leading_price_average_cents": {
"name": "Leading 24h Average Price"
},
"trailing_price_min_cents": {
"name": "Trailing 24h Minimum Price"
},
"trailing_price_max_cents": {
"name": "Trailing 24h Maximum Price"
},
"leading_price_min_cents": {
"name": "Leading 24h Minimum Price"
},
"leading_price_max_cents": {
"name": "Leading 24h Maximum Price"
},
"price_rating": {
"name": "Current Price Rating"
},
"next_interval_price_rating": {
"name": "Next Price Rating"
},
"previous_interval_price_rating": {
"name": "Previous Price Rating"
},
"current_hour_price_rating": {
"name": "Current Hour Price Rating"
},
"next_hour_price_rating": {
"name": "Next Hour Price Rating"
},
"daily_rating": {
"name": "Daily Price Rating"
},

View file

@ -7,6 +7,9 @@ name = "tibber_prices"
version = "0.1.0"
requires-python = ">=3.13"
[tool.setuptools]
packages = ["custom_components.tibber_prices"]
[tool.ruff]
# Based on https://github.com/home-assistant/core/blob/dev/pyproject.toml
target-version = "py313"