feat(sensors): expose energy/tax breakdown as sensor attributes

Add energy_price and tax attributes to interval and daily stat sensors:

- Interval sensors (current/next/previous): energy_price and tax from
  the specific 15-minute interval
- Daily min/max sensors: energy_price and tax from the extreme interval
- Daily average sensors: energy_price_mean, energy_price_median,
  tax_mean, tax_median — matching the existing mean/median pattern
  used for the main price attribute

Calculator caches both mean and median for energy/tax using
calculate_median() from utils/average. All new attributes are
excluded from Recorder to prevent database bloat.

Impact: Users can see price composition (spot price vs. taxes) on
all major price sensors. Enables solar feed-in and net metering
automations based on raw energy prices.
This commit is contained in:
Julian Pawlowski 2026-04-09 18:27:36 +00:00
parent f5dcf04aab
commit edabb49309
4 changed files with 130 additions and 4 deletions

View file

@ -4,7 +4,10 @@ from __future__ import annotations
from typing import TYPE_CHECKING
from custom_components.tibber_prices.const import PRICE_RATING_MAPPING
from custom_components.tibber_prices.const import (
PRICE_RATING_MAPPING,
get_display_unit_factor,
)
from custom_components.tibber_prices.coordinator.helpers import (
get_intervals_for_day_offsets,
)
@ -19,6 +22,43 @@ if TYPE_CHECKING:
from .helpers import add_alternate_average_attribute
def _add_energy_tax_from_interval(
attributes: dict,
interval_data: dict,
*,
config_entry: TibberPricesConfigEntry,
) -> None:
"""Add energy_price and tax from a single interval dict."""
factor = get_display_unit_factor(config_entry)
energy = interval_data.get("energy")
if energy is not None:
attributes["energy_price"] = round(float(energy) * factor, 2)
tax = interval_data.get("tax")
if tax is not None:
attributes["tax"] = round(float(tax) * factor, 2)
def _add_energy_tax_averages_from_cache(
attributes: dict,
cached_data: dict,
*,
config_entry: TibberPricesConfigEntry,
) -> None:
"""Add cached mean/median energy_price and tax values."""
energy_mean, energy_median, tax_mean, tax_median = cached_data.get(
"last_energy_tax_averages", (None, None, None, None)
)
factor = get_display_unit_factor(config_entry)
if energy_mean is not None:
attributes["energy_price_mean"] = round(float(energy_mean) * factor, 2)
if energy_median is not None:
attributes["energy_price_median"] = round(float(energy_median) * factor, 2)
if tax_mean is not None:
attributes["tax_mean"] = round(float(tax_mean) * factor, 2)
if tax_median is not None:
attributes["tax_median"] = round(float(tax_median) * factor, 2)
def _get_day_midnight_timestamp(key: str, *, time: TibberPricesTimeService) -> datetime:
"""Get midnight timestamp for a given day sensor key (returns datetime object)."""
# Determine which day based on sensor key
@ -117,7 +157,7 @@ def add_statistics_attributes(
)
return
# Extreme value sensors - show when the extreme occurs
# Extreme value sensors - show when the extreme occurs + energy/tax breakdown
extreme_sensors = {
"lowest_price_today",
"highest_price_today",
@ -125,10 +165,13 @@ def add_statistics_attributes(
"highest_price_tomorrow",
}
if key in extreme_sensors:
if cached_data.get("last_extreme_interval"):
extreme_starts_at = cached_data["last_extreme_interval"].get("startsAt")
extreme_interval = cached_data.get("last_extreme_interval")
if extreme_interval:
extreme_starts_at = extreme_interval.get("startsAt")
if extreme_starts_at:
attributes["timestamp"] = extreme_starts_at
# Add energy_price and tax from the extreme interval
_add_energy_tax_from_interval(attributes, extreme_interval, config_entry=config_entry)
return
# Daily average sensors - show midnight to indicate whole day + add alternate value
@ -142,6 +185,8 @@ def add_statistics_attributes(
key, # base_key = key itself ("average_price_today" or "average_price_tomorrow")
config_entry=config_entry,
)
# Add energy/tax averages from cached calculator data
_add_energy_tax_averages_from_cache(attributes, cached_data, config_entry=config_entry)
return
# Daily aggregated level/rating sensors - show midnight to indicate whole day

View file

@ -8,6 +8,7 @@ from typing import TYPE_CHECKING, Any
from custom_components.tibber_prices.const import (
PRICE_LEVEL_MAPPING,
PRICE_RATING_MAPPING,
get_display_unit_factor,
)
from custom_components.tibber_prices.entity_utils import add_icon_color_attribute
from custom_components.tibber_prices.utils.price import find_price_data_for_interval
@ -89,6 +90,38 @@ def _get_interval_data_for_attributes(
return None
def _add_energy_tax_attributes(
attributes: dict,
interval_data: dict | None,
*,
config_entry: TibberPricesConfigEntry,
) -> None:
"""
Add energy_price and tax attributes from interval data.
The API provides `energy` (raw spot price) and `tax` (tax component) in base
currency. These are converted to display units using the same factor as `total`.
Args:
attributes: Dictionary to add attributes to
interval_data: Price interval data dict (may contain energy/tax fields)
config_entry: Config entry for display unit preference
"""
if not interval_data:
return
factor = get_display_unit_factor(config_entry)
energy = interval_data.get("energy")
if energy is not None:
attributes["energy_price"] = round(float(energy) * factor, 2)
tax = interval_data.get("tax")
if tax is not None:
attributes["tax"] = round(float(tax) * factor, 2)
def add_current_interval_price_attributes( # noqa: PLR0913
attributes: dict,
key: str,
@ -126,6 +159,9 @@ def add_current_interval_price_attributes( # noqa: PLR0913
if interval_data and "level" in interval_data:
level = interval_data["level"]
add_icon_color_attribute(attributes, key="price_level", state_value=level)
# Add energy_price and tax attributes from raw API data
_add_energy_tax_attributes(attributes, interval_data, config_entry=config_entry)
elif key in ["current_hour_average_price", "next_hour_average_price"]:
# For hour-based price sensors, get level from cached_data
level = cached_data.get("rolling_hour_level")

View file

@ -15,6 +15,7 @@ from custom_components.tibber_prices.sensor.helpers import (
aggregate_level_data,
aggregate_rating_data,
)
from custom_components.tibber_prices.utils.average import calculate_median
from .base import TibberPricesBaseCalculator
@ -44,6 +45,10 @@ class TibberPricesDailyStatCalculator(TibberPricesBaseCalculator):
"""
super().__init__(coordinator)
self._last_extreme_interval: dict | None = None
self._last_energy_mean: float | None = None
self._last_energy_median: float | None = None
self._last_tax_mean: float | None = None
self._last_tax_median: float | None = None
def get_daily_stat_value(
self,
@ -107,6 +112,8 @@ class TibberPricesDailyStatCalculator(TibberPricesBaseCalculator):
# Store the interval (for avg, use first interval as reference)
if price_intervals:
self._last_extreme_interval = price_intervals[0]["interval"]
# Compute and cache energy/tax averages for attribute builders
self._cache_energy_tax_averages(price_intervals)
# Convert to display currency units based on config
avg_result = round(get_price_value(value, config_entry=self.coordinator.config_entry), 2)
median_result = (
@ -198,3 +205,34 @@ class TibberPricesDailyStatCalculator(TibberPricesBaseCalculator):
"""
return self._last_extreme_interval
def get_last_energy_tax_averages(
self,
) -> tuple[float | None, float | None, float | None, float | None]:
"""
Get cached mean and median energy and tax values from last average calculation.
Returns:
Tuple of (energy_mean, energy_median, tax_mean, tax_median) in base currency,
or (None, None, None, None).
"""
return self._last_energy_mean, self._last_energy_median, self._last_tax_mean, self._last_tax_median
def _cache_energy_tax_averages(self, price_intervals: list[dict]) -> None:
"""Compute and cache energy/tax mean and median from price intervals."""
energy_prices: list[float] = []
tax_prices: list[float] = []
for pi in price_intervals:
interval = pi["interval"]
energy = interval.get("energy")
if energy is not None:
energy_prices.append(float(energy))
tax = interval.get("tax")
if tax is not None:
tax_prices.append(float(tax))
self._last_energy_mean = sum(energy_prices) / len(energy_prices) if energy_prices else None
self._last_energy_median = calculate_median(energy_prices) if energy_prices else None
self._last_tax_mean = sum(tax_prices) / len(tax_prices) if tax_prices else None
self._last_tax_median = calculate_median(tax_prices) if tax_prices else None

View file

@ -138,6 +138,12 @@ class TibberPricesSensor(TibberPricesEntity, RestoreSensor):
"second_half_12h_diff_from_current_%",
# Static/Rarely Changing
"tomorrow_expected_after",
"energy_price",
"energy_price_mean",
"energy_price_median",
"tax",
"tax_mean",
"tax_median",
"level_value",
"rating_value",
"level_id",
@ -1145,6 +1151,7 @@ class TibberPricesSensor(TibberPricesEntity, RestoreSensor):
"trend_change_attributes": self._trend_calculator.get_trend_change_attributes(),
"volatility_attributes": self._volatility_calculator.get_volatility_attributes(),
"last_extreme_interval": self._daily_stat_calculator.get_last_extreme_interval(),
"last_energy_tax_averages": self._daily_stat_calculator.get_last_energy_tax_averages(),
"last_price_level": self._interval_calculator.get_last_price_level(),
"last_rating_difference": self._interval_calculator.get_last_rating_difference(),
"last_rating_level": self._interval_calculator.get_last_rating_level(),