feat(sensor): use dynamic precision for price rounding and display

Add get_display_precision() to const.py returning DISPLAY_PRECISION_SUBUNIT (2)
or DISPLAY_PRECISION_BASE (4) based on config. Replace hardcoded round(..., 2)
with get_display_precision() in all calculators and attribute builders.
Add _update_suggested_precision() to sensor core; syncs entity registry
suggested_display_precision on every coordinator update.

Interval price sensors get full precision (2 or 4 dp); other MONETARY sensors
get half precision (1 or 2 dp) as sensible default.

Impact: Price sensor states and attributes now correctly use 4 decimal places
in base-currency mode (was always 2). Display precision in dashboards updates
automatically when currency mode changes.
This commit is contained in:
Julian Pawlowski 2026-04-14 19:28:19 +00:00
parent 6d22ea7151
commit a4ad506e01
13 changed files with 208 additions and 207 deletions

View file

@ -9,12 +9,7 @@ from typing import TYPE_CHECKING, Any
import aiofiles import aiofiles
from homeassistant.const import ( from homeassistant.const import CURRENCY_DOLLAR, CURRENCY_EURO, UnitOfPower, UnitOfTime
CURRENCY_DOLLAR,
CURRENCY_EURO,
UnitOfPower,
UnitOfTime,
)
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import Sequence from collections.abc import Sequence
@ -29,6 +24,11 @@ LOGGER = logging.getLogger(__package__)
DATA_CHART_CONFIG = "chart_config" # Key for chart export config in hass.data DATA_CHART_CONFIG = "chart_config" # Key for chart export config in hass.data
DATA_CHART_METADATA_CONFIG = "chart_metadata_config" # Key for chart metadata config in hass.data DATA_CHART_METADATA_CONFIG = "chart_metadata_config" # Key for chart metadata config in hass.data
# Config entry data flag: set when user switches currency display mode.
# Triggers a fresh (un-dismissed) repair issue on every setup/reload until
# the user explicitly re-saves the currency settings to acknowledge.
DATA_STATISTICS_REVIEW_REQUIRED = "statistics_review_required"
# Configuration keys # Configuration keys
CONF_EXTENDED_DESCRIPTIONS = "extended_descriptions" CONF_EXTENDED_DESCRIPTIONS = "extended_descriptions"
CONF_VIRTUAL_TIME_OFFSET_DAYS = ( CONF_VIRTUAL_TIME_OFFSET_DAYS = (
@ -462,14 +462,42 @@ def get_display_unit_factor(config_entry: ConfigEntry) -> int:
Example: Example:
price_base = 0.2534 # Internal: 0.2534 €/kWh price_base = 0.2534 # Internal: 0.2534 €/kWh
factor = get_display_unit_factor(config_entry) factor = get_display_unit_factor(config_entry)
display_value = round(price_base * factor, 2) precision = get_display_precision(config_entry)
# → 25.34 ct/kWh (subunit) or 0.25 €/kWh (base) display_value = round(price_base * factor, precision)
# → 25.34 ct/kWh (subunit, 2 decimals) or 0.2534 €/kWh (base, 4 decimals)
""" """
display_mode = config_entry.options.get(CONF_CURRENCY_DISPLAY_MODE, DISPLAY_MODE_SUBUNIT) display_mode = config_entry.options.get(CONF_CURRENCY_DISPLAY_MODE, DISPLAY_MODE_SUBUNIT)
return 100 if display_mode == DISPLAY_MODE_SUBUNIT else 1 return 100 if display_mode == DISPLAY_MODE_SUBUNIT else 1
# Rounding precision constants for display currency
DISPLAY_PRECISION_SUBUNIT = 2 # Decimal places for subunit currency (ct, øre)
DISPLAY_PRECISION_BASE = 4 # Decimal places for base currency (€, kr)
def get_display_precision(config_entry: ConfigEntry) -> int:
"""
Get decimal precision for rounding prices in the configured display currency.
Subunit currencies (ct, øre) use 2 decimal places (e.g., 25.34 ct/kWh).
Base currencies (, kr) use 4 decimal places (e.g., 0.2534 /kWh).
This ensures sufficient precision for all currency modes:
- Subunit: 2 decimals (the sub-cent level is rarely meaningful)
- Base: 4 decimals (preserves full API precision for EUR/NOK/SEK prices)
Args:
config_entry: ConfigEntry with currency_display_mode option
Returns:
2 for subunit currency, 4 for base currency
"""
display_mode = config_entry.options.get(CONF_CURRENCY_DISPLAY_MODE, DISPLAY_MODE_SUBUNIT)
return DISPLAY_PRECISION_SUBUNIT if display_mode == DISPLAY_MODE_SUBUNIT else DISPLAY_PRECISION_BASE
def get_display_unit_string(config_entry: ConfigEntry, currency_code: str | None) -> str: def get_display_unit_string(config_entry: ConfigEntry, currency_code: str | None) -> str:
""" """
Get unit string for display based on configuration. Get unit string for display based on configuration.

View file

@ -14,7 +14,7 @@ from __future__ import annotations
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from custom_components.tibber_prices.const import get_display_unit_factor from custom_components.tibber_prices.const import get_display_precision, get_display_unit_factor
if TYPE_CHECKING: if TYPE_CHECKING:
from datetime import datetime from datetime import datetime
@ -55,7 +55,8 @@ def get_price_value(
# New mode: use config_entry # New mode: use config_entry
if config_entry is not None: if config_entry is not None:
factor = get_display_unit_factor(config_entry) factor = get_display_unit_factor(config_entry)
return round(price * factor, 2) precision = get_display_precision(config_entry)
return round(price * factor, precision)
# Fallback: default to subunit currency (backward compatibility) # Fallback: default to subunit currency (backward compatibility)
return round(price * 100, 2) return round(price * 100, 2)

View file

@ -35,6 +35,7 @@ class TibberPricesIconContext:
has_future_periods_callback: Callable[[], bool] | None = None has_future_periods_callback: Callable[[], bool] | None = None
period_is_active_callback: Callable[[], bool] | None = None period_is_active_callback: Callable[[], bool] | None = None
time: TibberPricesTimeService | None = None time: TibberPricesTimeService | None = None
trend_change_direction: str | None = None # For next_price_trend_change icon lookup
if TYPE_CHECKING: if TYPE_CHECKING:
@ -74,7 +75,7 @@ def get_dynamic_icon(
# Try various icon sources in order # Try various icon sources in order
return ( return (
get_trend_icon(key, value) get_trend_icon(key, value, context=ctx)
or get_timing_sensor_icon(key, value, period_is_active_callback=ctx.period_is_active_callback) or get_timing_sensor_icon(key, value, period_is_active_callback=ctx.period_is_active_callback)
or get_price_sensor_icon(key, ctx.coordinator_data, time=ctx.time) or get_price_sensor_icon(key, ctx.coordinator_data, time=ctx.time)
or get_level_sensor_icon(key, value) or get_level_sensor_icon(key, value)
@ -84,12 +85,24 @@ def get_dynamic_icon(
) )
def get_trend_icon(key: str, value: Any) -> str | None: # 5-level trend icons: strongly uses double arrows, normal uses single
_TREND_ICONS = {
"strongly_rising": "mdi:chevron-double-up",
"rising": "mdi:trending-up",
"stable": "mdi:trending-neutral",
"falling": "mdi:trending-down",
"strongly_falling": "mdi:chevron-double-down",
}
def get_trend_icon(key: str, value: Any, *, context: TibberPricesIconContext | None = None) -> str | None:
"""Get icon for trend sensors using 5-level trend scale.""" """Get icon for trend sensors using 5-level trend scale."""
# Handle next_price_trend_change TIMESTAMP sensor differently # next_price_trend_change is a TIMESTAMP sensor — icon comes from direction attribute
# (icon based on attributes, not value which is a timestamp)
if key == "next_price_trend_change": if key == "next_price_trend_change":
return None # Will be handled by sensor's icon property using attributes direction = context.trend_change_direction if context else None
if isinstance(direction, str):
return _TREND_ICONS.get(direction, "mdi:help-circle-outline")
return "mdi:help-circle-outline"
if not key.startswith(("price_trend_", "price_outlook_", "price_trajectory_")) and key != "current_price_trend": if not key.startswith(("price_trend_", "price_outlook_", "price_trajectory_")) and key != "current_price_trend":
return None return None
@ -97,15 +110,7 @@ def get_trend_icon(key: str, value: Any) -> str | None:
if not isinstance(value, str): if not isinstance(value, str):
return None return None
# 5-level trend icons: strongly uses double arrows, normal uses single return _TREND_ICONS.get(value, "mdi:help-circle-outline")
trend_icons = {
"strongly_rising": "mdi:chevron-double-up", # Strong upward movement
"rising": "mdi:trending-up", # Normal upward trend
"stable": "mdi:trending-neutral", # No significant change
"falling": "mdi:trending-down", # Normal downward trend
"strongly_falling": "mdi:chevron-double-down", # Strong downward movement
}
return trend_icons.get(value)
def get_timing_sensor_icon( def get_timing_sensor_icon(

View file

@ -4,13 +4,8 @@ from __future__ import annotations
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from custom_components.tibber_prices.const import ( from custom_components.tibber_prices.const import PRICE_RATING_MAPPING, get_display_precision, get_display_unit_factor
PRICE_RATING_MAPPING, from custom_components.tibber_prices.coordinator.helpers import get_intervals_for_day_offsets
get_display_unit_factor,
)
from custom_components.tibber_prices.coordinator.helpers import (
get_intervals_for_day_offsets,
)
from homeassistant.const import PERCENTAGE from homeassistant.const import PERCENTAGE
if TYPE_CHECKING: if TYPE_CHECKING:
@ -30,12 +25,13 @@ def _add_energy_tax_from_interval(
) -> None: ) -> None:
"""Add energy_price and tax from a single interval dict.""" """Add energy_price and tax from a single interval dict."""
factor = get_display_unit_factor(config_entry) factor = get_display_unit_factor(config_entry)
precision = get_display_precision(config_entry)
energy = interval_data.get("energy") energy = interval_data.get("energy")
if energy is not None: if energy is not None:
attributes["energy_price"] = round(float(energy) * factor, 2) attributes["energy_price"] = round(float(energy) * factor, precision)
tax = interval_data.get("tax") tax = interval_data.get("tax")
if tax is not None: if tax is not None:
attributes["tax"] = round(float(tax) * factor, 2) attributes["tax"] = round(float(tax) * factor, precision)
def _add_energy_tax_averages_from_cache( def _add_energy_tax_averages_from_cache(
@ -49,14 +45,15 @@ def _add_energy_tax_averages_from_cache(
"last_energy_tax_averages", (None, None, None, None) "last_energy_tax_averages", (None, None, None, None)
) )
factor = get_display_unit_factor(config_entry) factor = get_display_unit_factor(config_entry)
precision = get_display_precision(config_entry)
if energy_mean is not None: if energy_mean is not None:
attributes["energy_price_mean"] = round(float(energy_mean) * factor, 2) attributes["energy_price_mean"] = round(float(energy_mean) * factor, precision)
if energy_median is not None: if energy_median is not None:
attributes["energy_price_median"] = round(float(energy_median) * factor, 2) attributes["energy_price_median"] = round(float(energy_median) * factor, precision)
if tax_mean is not None: if tax_mean is not None:
attributes["tax_mean"] = round(float(tax_mean) * factor, 2) attributes["tax_mean"] = round(float(tax_mean) * factor, precision)
if tax_median is not None: if tax_median is not None:
attributes["tax_median"] = round(float(tax_median) * factor, 2) attributes["tax_median"] = round(float(tax_median) * factor, precision)
def _get_day_midnight_timestamp(key: str, *, time: TibberPricesTimeService) -> datetime: def _get_day_midnight_timestamp(key: str, *, time: TibberPricesTimeService) -> datetime:

View file

@ -4,13 +4,11 @@ from __future__ import annotations
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from custom_components.tibber_prices.const import get_display_unit_factor from custom_components.tibber_prices.const import get_display_precision, get_display_unit_factor
from custom_components.tibber_prices.coordinator.helpers import get_intervals_for_day_offsets from custom_components.tibber_prices.coordinator.helpers import get_intervals_for_day_offsets
if TYPE_CHECKING: if TYPE_CHECKING:
from custom_components.tibber_prices.coordinator.core import ( from custom_components.tibber_prices.coordinator.core import TibberPricesDataUpdateCoordinator
TibberPricesDataUpdateCoordinator,
)
from custom_components.tibber_prices.coordinator.time_service import TibberPricesTimeService from custom_components.tibber_prices.coordinator.time_service import TibberPricesTimeService
from custom_components.tibber_prices.data import TibberPricesConfigEntry from custom_components.tibber_prices.data import TibberPricesConfigEntry
@ -20,7 +18,7 @@ from .helpers import add_alternate_average_attribute
MAX_FORECAST_INTERVALS = 8 # Show up to 8 future intervals (2 hours with 15-min intervals) MAX_FORECAST_INTERVALS = 8 # Show up to 8 future intervals (2 hours with 15-min intervals)
def add_next_avg_attributes( # noqa: PLR0913 def add_next_avg_attributes(
attributes: dict, attributes: dict,
key: str, key: str,
coordinator: TibberPricesDataUpdateCoordinator, coordinator: TibberPricesDataUpdateCoordinator,
@ -142,7 +140,8 @@ def get_future_prices(
# Convert to display currency unit based on configuration # Convert to display currency unit based on configuration
price_major = float(price_data["total"]) price_major = float(price_data["total"])
factor = get_display_unit_factor(config_entry) factor = get_display_unit_factor(config_entry)
price_display = round(price_major * factor, 2) precision = get_display_precision(config_entry)
price_display = round(price_major * factor, precision)
future_prices.append( future_prices.append(
{ {

View file

@ -8,15 +8,14 @@ from typing import TYPE_CHECKING, Any
from custom_components.tibber_prices.const import ( from custom_components.tibber_prices.const import (
PRICE_LEVEL_MAPPING, PRICE_LEVEL_MAPPING,
PRICE_RATING_MAPPING, PRICE_RATING_MAPPING,
get_display_precision,
get_display_unit_factor, get_display_unit_factor,
) )
from custom_components.tibber_prices.entity_utils import add_icon_color_attribute 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 from custom_components.tibber_prices.utils.price import find_price_data_for_interval
if TYPE_CHECKING: if TYPE_CHECKING:
from custom_components.tibber_prices.coordinator.core import ( from custom_components.tibber_prices.coordinator.core import TibberPricesDataUpdateCoordinator
TibberPricesDataUpdateCoordinator,
)
from custom_components.tibber_prices.coordinator.time_service import TibberPricesTimeService from custom_components.tibber_prices.coordinator.time_service import TibberPricesTimeService
from custom_components.tibber_prices.data import TibberPricesConfigEntry from custom_components.tibber_prices.data import TibberPricesConfigEntry
@ -112,17 +111,18 @@ def _add_energy_tax_attributes(
return return
factor = get_display_unit_factor(config_entry) factor = get_display_unit_factor(config_entry)
precision = get_display_precision(config_entry)
energy = interval_data.get("energy") energy = interval_data.get("energy")
if energy is not None: if energy is not None:
attributes["energy_price"] = round(float(energy) * factor, 2) attributes["energy_price"] = round(float(energy) * factor, precision)
tax = interval_data.get("tax") tax = interval_data.get("tax")
if tax is not None: if tax is not None:
attributes["tax"] = round(float(tax) * factor, 2) attributes["tax"] = round(float(tax) * factor, precision)
def add_current_interval_price_attributes( # noqa: PLR0913 def add_current_interval_price_attributes(
attributes: dict, attributes: dict,
key: str, key: str,
coordinator: TibberPricesDataUpdateCoordinator, coordinator: TibberPricesDataUpdateCoordinator,
@ -198,7 +198,7 @@ def add_current_interval_price_attributes( # noqa: PLR0913
) )
def add_level_attributes_for_sensor( # noqa: PLR0913 def add_level_attributes_for_sensor(
attributes: dict, attributes: dict,
key: str, key: str,
interval_data: dict | None, interval_data: dict | None,
@ -252,7 +252,7 @@ def add_price_level_attributes(attributes: dict, level: str) -> None:
add_icon_color_attribute(attributes, key="price_level", state_value=level) add_icon_color_attribute(attributes, key="price_level", state_value=level)
def add_rating_attributes_for_sensor( # noqa: PLR0913 def add_rating_attributes_for_sensor(
attributes: dict, attributes: dict,
key: str, key: str,
interval_data: dict | None, interval_data: dict | None,

View file

@ -9,12 +9,10 @@ from custom_components.tibber_prices.const import (
CONF_PRICE_RATING_THRESHOLD_LOW, CONF_PRICE_RATING_THRESHOLD_LOW,
DEFAULT_PRICE_RATING_THRESHOLD_HIGH, DEFAULT_PRICE_RATING_THRESHOLD_HIGH,
DEFAULT_PRICE_RATING_THRESHOLD_LOW, DEFAULT_PRICE_RATING_THRESHOLD_LOW,
get_display_precision,
) )
from custom_components.tibber_prices.entity_utils import get_price_value from custom_components.tibber_prices.entity_utils import get_price_value
from custom_components.tibber_prices.sensor.helpers import ( from custom_components.tibber_prices.sensor.helpers import aggregate_level_data, aggregate_rating_data
aggregate_level_data,
aggregate_rating_data,
)
from custom_components.tibber_prices.utils.average import calculate_median from custom_components.tibber_prices.utils.average import calculate_median
from .base import TibberPricesBaseCalculator from .base import TibberPricesBaseCalculator
@ -22,9 +20,7 @@ from .base import TibberPricesBaseCalculator
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import Callable from collections.abc import Callable
from custom_components.tibber_prices.coordinator import ( from custom_components.tibber_prices.coordinator import TibberPricesDataUpdateCoordinator
TibberPricesDataUpdateCoordinator,
)
class TibberPricesDailyStatCalculator(TibberPricesBaseCalculator): class TibberPricesDailyStatCalculator(TibberPricesBaseCalculator):
@ -115,9 +111,10 @@ class TibberPricesDailyStatCalculator(TibberPricesBaseCalculator):
# Compute and cache energy/tax averages for attribute builders # Compute and cache energy/tax averages for attribute builders
self._cache_energy_tax_averages(price_intervals) self._cache_energy_tax_averages(price_intervals)
# Convert to display currency units based on config # Convert to display currency units based on config
avg_result = round(get_price_value(value, config_entry=self.coordinator.config_entry), 2) precision = get_display_precision(self.coordinator.config_entry)
avg_result = round(get_price_value(value, config_entry=self.coordinator.config_entry), precision)
median_result = ( median_result = (
round(get_price_value(median, config_entry=self.coordinator.config_entry), 2) round(get_price_value(median, config_entry=self.coordinator.config_entry), precision)
if median is not None if median is not None
else None else None
) )
@ -132,9 +129,10 @@ class TibberPricesDailyStatCalculator(TibberPricesBaseCalculator):
self._last_extreme_interval = pi["interval"] self._last_extreme_interval = pi["interval"]
break break
# Return in configured display currency units with 2 decimals # Return in configured display currency units
precision = get_display_precision(self.coordinator.config_entry)
result = get_price_value(value, config_entry=self.coordinator.config_entry) result = get_price_value(value, config_entry=self.coordinator.config_entry)
return round(result, 2) return round(result, precision)
def get_daily_aggregated_value( def get_daily_aggregated_value(
self, self,

View file

@ -4,14 +4,12 @@ from __future__ import annotations
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from custom_components.tibber_prices.const import get_display_unit_factor from custom_components.tibber_prices.const import get_display_precision, get_display_unit_factor
from .base import TibberPricesBaseCalculator from .base import TibberPricesBaseCalculator
if TYPE_CHECKING: if TYPE_CHECKING:
from custom_components.tibber_prices.coordinator import ( from custom_components.tibber_prices.coordinator import TibberPricesDataUpdateCoordinator
TibberPricesDataUpdateCoordinator,
)
class TibberPricesIntervalCalculator(TibberPricesBaseCalculator): class TibberPricesIntervalCalculator(TibberPricesBaseCalculator):
@ -36,7 +34,7 @@ class TibberPricesIntervalCalculator(TibberPricesBaseCalculator):
self._last_rating_level: str | None = None self._last_rating_level: str | None = None
self._last_rating_difference: float | None = None self._last_rating_difference: float | None = None
def get_interval_value( # noqa: PLR0911 def get_interval_value(
self, self,
*, *,
interval_offset: int, interval_offset: int,
@ -74,7 +72,8 @@ class TibberPricesIntervalCalculator(TibberPricesBaseCalculator):
if in_euro: if in_euro:
return price return price
factor = get_display_unit_factor(self.config_entry) factor = get_display_unit_factor(self.config_entry)
return round(price * factor, 2) precision = get_display_precision(self.config_entry)
return round(price * factor, precision)
if value_type == "level": if value_type == "level":
level = self.safe_get_from_interval(interval_data, "level") level = self.safe_get_from_interval(interval_data, "level")

View file

@ -15,22 +15,18 @@ Caching strategy:
from typing import TYPE_CHECKING, Any, ClassVar from typing import TYPE_CHECKING, Any, ClassVar
from custom_components.tibber_prices.const import get_display_unit_factor from custom_components.tibber_prices.const import get_display_precision, get_display_unit_factor
from custom_components.tibber_prices.coordinator.helpers import get_intervals_for_day_offsets from custom_components.tibber_prices.coordinator.helpers import get_intervals_for_day_offsets
from custom_components.tibber_prices.entity_utils.colors import get_icon_color
from custom_components.tibber_prices.utils.average import calculate_mean, calculate_next_n_hours_mean from custom_components.tibber_prices.utils.average import calculate_mean, calculate_next_n_hours_mean
from custom_components.tibber_prices.utils.price import ( from custom_components.tibber_prices.utils.price import calculate_price_trend, find_price_data_for_interval
calculate_price_trend,
find_price_data_for_interval,
)
from .base import TibberPricesBaseCalculator from .base import TibberPricesBaseCalculator
if TYPE_CHECKING: if TYPE_CHECKING:
from datetime import datetime from datetime import datetime
from custom_components.tibber_prices.coordinator import ( from custom_components.tibber_prices.coordinator import TibberPricesDataUpdateCoordinator
TibberPricesDataUpdateCoordinator,
)
# Constants # Constants
MIN_HOURS_FOR_LATER_HALF = 1 # Minimum hours needed to calculate half-window averages (activates at 2h+) MIN_HOURS_FOR_LATER_HALF = 1 # Minimum hours needed to calculate half-window averages (activates at 2h+)
@ -62,7 +58,7 @@ class TibberPricesTrendCalculator(TibberPricesBaseCalculator):
"strongly_rising": "rising", "strongly_rising": "rising",
} }
def __init__(self, coordinator: "TibberPricesDataUpdateCoordinator") -> None: def __init__(self, coordinator: TibberPricesDataUpdateCoordinator) -> None:
"""Initialize trend calculator with caching state.""" """Initialize trend calculator with caching state."""
super().__init__(coordinator) super().__init__(coordinator)
# Per-sensor caches (for price_outlook_Xh and price_trajectory_Xh sensors) # Per-sensor caches (for price_outlook_Xh and price_trajectory_Xh sensors)
@ -168,18 +164,12 @@ class TibberPricesTrendCalculator(TibberPricesBaseCalculator):
volatility_threshold_high=volatility_threshold_high, volatility_threshold_high=volatility_threshold_high,
) )
# Determine icon color based on trend state (5-level scale) # Determine icon color via centralized mapping (same as colors.py)
# Strongly rising/falling uses more intense colors icon_color = get_icon_color(f"price_outlook_{hours}h", trend_state) or "var(--state-icon-color)"
icon_color = {
"strongly_rising": "var(--error-color)", # Red for strongly rising (very expensive)
"rising": "var(--warning-color)", # Orange/Yellow for rising prices
"stable": "var(--state-icon-color)", # Default gray for stable prices
"falling": "var(--success-color)", # Green for falling prices (cheaper)
"strongly_falling": "var(--success-color)", # Green for strongly falling (great deal)
}.get(trend_state, "var(--state-icon-color)")
# Convert prices to display currency unit based on configuration # Convert prices to display currency unit based on configuration
factor = get_display_unit_factor(self.config_entry) factor = get_display_unit_factor(self.config_entry)
precision = get_display_precision(self.config_entry)
# Store attributes in sensor-specific dictionary AND cache the trend value # Store attributes in sensor-specific dictionary AND cache the trend value
# Show effective thresholds (after volatility adjustment) so users can understand # Show effective thresholds (after volatility adjustment) so users can understand
@ -188,7 +178,7 @@ class TibberPricesTrendCalculator(TibberPricesBaseCalculator):
"timestamp": next_interval_start, "timestamp": next_interval_start,
"trend_value": trend_value, "trend_value": trend_value,
f"trend_{hours}h_%": round(diff_pct, 1), f"trend_{hours}h_%": round(diff_pct, 1),
f"next_{hours}h_avg": round(future_mean * factor, 2), f"next_{hours}h_avg": round(future_mean * factor, precision),
"interval_count": lookahead_intervals, "interval_count": lookahead_intervals,
"threshold_rising_%": round(threshold_rising * vol_factor, 1), "threshold_rising_%": round(threshold_rising * vol_factor, 1),
"threshold_rising_strongly_%": round(threshold_strongly_rising * vol_factor, 1), "threshold_rising_strongly_%": round(threshold_strongly_rising * vol_factor, 1),
@ -203,7 +193,7 @@ class TibberPricesTrendCalculator(TibberPricesBaseCalculator):
# Get second half average for longer periods # Get second half average for longer periods
later_half_avg = self._calculate_later_half_average(hours, next_interval_start) later_half_avg = self._calculate_later_half_average(hours, next_interval_start)
if later_half_avg is not None: if later_half_avg is not None:
self._trend_attributes[f"second_half_{hours}h_avg"] = round(later_half_avg * factor, 2) self._trend_attributes[f"second_half_{hours}h_avg"] = round(later_half_avg * factor, precision)
# Calculate incremental change: how much does the later half differ from current? # Calculate incremental change: how much does the later half differ from current?
# CRITICAL: Use abs() for negative prices and allow calculation for all non-zero prices # CRITICAL: Use abs() for negative prices and allow calculation for all non-zero prices
@ -237,15 +227,17 @@ class TibberPricesTrendCalculator(TibberPricesBaseCalculator):
# price direction BEFORE the current trend (binary: rising/falling), # price direction BEFORE the current trend (binary: rising/falling),
# not the trend classification. next_price_trend_change uses "from_direction" # not the trend classification. next_price_trend_change uses "from_direction"
# for the current 5-level trend state. # for the current 5-level trend state.
current_trend_state = trend_info["current_trend_state"]
self._current_trend_attributes = { self._current_trend_attributes = {
"previous_direction": trend_info["from_direction"], "previous_direction": trend_info["from_direction"],
"price_direction_duration_minutes": trend_info["trend_duration_minutes"], "price_direction_duration_minutes": trend_info["trend_duration_minutes"],
"price_direction_since": ( "price_direction_since": (
trend_info["trend_start_time"].isoformat() if trend_info["trend_start_time"] else None trend_info["trend_start_time"].isoformat() if trend_info["trend_start_time"] else None
), ),
"icon_color": get_icon_color("current_price_trend", current_trend_state),
} }
return trend_info["current_trend_state"] return current_trend_state
def get_next_trend_change_value(self) -> datetime | None: def get_next_trend_change_value(self) -> datetime | None:
""" """
@ -308,7 +300,7 @@ class TibberPricesTrendCalculator(TibberPricesBaseCalculator):
Trend state: "rising" | "falling" | "stable", or None if unavailable Trend state: "rising" | "falling" | "stable", or None if unavailable
""" """
if hours < 2: # noqa: PLR2004 if hours < 2:
return None return None
if not self.has_data(): if not self.has_data():
@ -378,6 +370,7 @@ class TibberPricesTrendCalculator(TibberPricesBaseCalculator):
) )
factor = get_display_unit_factor(self.config_entry) factor = get_display_unit_factor(self.config_entry)
precision = get_display_precision(self.config_entry)
time_obj = self.coordinator.time time_obj = self.coordinator.time
total_intervals = time_obj.minutes_to_intervals(hours * 60) total_intervals = time_obj.minutes_to_intervals(hours * 60)
first_half_count = total_intervals // 2 first_half_count = total_intervals // 2
@ -387,8 +380,8 @@ class TibberPricesTrendCalculator(TibberPricesBaseCalculator):
"timestamp": next_interval_start, "timestamp": next_interval_start,
"trend_value": trend_value, "trend_value": trend_value,
f"trajectory_{hours}h_%": round(diff_pct, 1), f"trajectory_{hours}h_%": round(diff_pct, 1),
f"first_half_{hours}h_avg": round(first_half_avg * factor, 2), f"first_half_{hours}h_avg": round(first_half_avg * factor, precision),
f"second_half_{hours}h_avg": round(second_half_avg * factor, 2), f"second_half_{hours}h_avg": round(second_half_avg * factor, precision),
f"first_half_{hours}h_diff_from_current_%": round( f"first_half_{hours}h_diff_from_current_%": round(
((first_half_avg - current_interval_price) / abs(current_interval_price)) * 100, 1 ((first_half_avg - current_interval_price) / abs(current_interval_price)) * 100, 1
) )
@ -402,6 +395,7 @@ class TibberPricesTrendCalculator(TibberPricesBaseCalculator):
"first_half_interval_count": first_half_count, "first_half_interval_count": first_half_count,
"second_half_interval_count": second_half_count, "second_half_interval_count": second_half_count,
"volatility_factor": vol_factor, "volatility_factor": vol_factor,
"icon_color": get_icon_color(f"price_trajectory_{hours}h", trajectory_state),
} }
return trajectory_state return trajectory_state
@ -910,16 +904,17 @@ class TibberPricesTrendCalculator(TibberPricesBaseCalculator):
change_price = float(change_interval["total"]) change_price = float(change_interval["total"])
minutes_until = time.minutes_until_rounded(change_time) minutes_until = time.minutes_until_rounded(change_time)
factor = get_display_unit_factor(self.config_entry) factor = get_display_unit_factor(self.config_entry)
precision = get_display_precision(self.config_entry)
vf = first_change["vol_factor"] vf = first_change["vol_factor"]
self._trend_change_attributes = { self._trend_change_attributes = {
"direction": first_change["trend"], "direction": first_change["trend"],
"from_direction": current_trend_state, "from_direction": current_trend_state,
"minutes_until_change": minutes_until, "minutes_until_change": minutes_until,
"price_now": round(float(current_interval["total"]) * factor, 2), "price_now": round(float(current_interval["total"]) * factor, precision),
"price_at_change": round(change_price * factor, 2), "price_at_change": round(change_price * factor, precision),
"price_avg_after_change": ( "price_avg_after_change": (
round(first_change["mean"] * factor, 2) if first_change["mean"] else None round(first_change["mean"] * factor, precision) if first_change["mean"] else None
), ),
"trend_diff_%": round(first_change["diff"], 1), "trend_diff_%": round(first_change["diff"], 1),
"threshold_rising_%": round(thresholds["rising"] * vf, 1), "threshold_rising_%": round(thresholds["rising"] * vf, 1),

View file

@ -12,14 +12,12 @@ from custom_components.tibber_prices.const import (
DEFAULT_VOLATILITY_THRESHOLD_HIGH, DEFAULT_VOLATILITY_THRESHOLD_HIGH,
DEFAULT_VOLATILITY_THRESHOLD_MODERATE, DEFAULT_VOLATILITY_THRESHOLD_MODERATE,
DEFAULT_VOLATILITY_THRESHOLD_VERY_HIGH, DEFAULT_VOLATILITY_THRESHOLD_VERY_HIGH,
get_display_precision,
get_display_unit_factor, get_display_unit_factor,
) )
from custom_components.tibber_prices.coordinator.helpers import get_intervals_for_day_offsets from custom_components.tibber_prices.coordinator.helpers import get_intervals_for_day_offsets
from custom_components.tibber_prices.entity_utils import add_icon_color_attribute, find_rolling_hour_center_index from custom_components.tibber_prices.entity_utils import add_icon_color_attribute, find_rolling_hour_center_index
from custom_components.tibber_prices.sensor.attributes import ( from custom_components.tibber_prices.sensor.attributes import add_volatility_type_attributes, get_prices_for_volatility
add_volatility_type_attributes,
get_prices_for_volatility,
)
from custom_components.tibber_prices.utils.average import calculate_mean from custom_components.tibber_prices.utils.average import calculate_mean
from custom_components.tibber_prices.utils.price import ( from custom_components.tibber_prices.utils.price import (
calculate_iqr_stats, calculate_iqr_stats,
@ -103,6 +101,7 @@ class TibberPricesVolatilityCalculator(TibberPricesBaseCalculator):
# Convert to display currency unit based on configuration # Convert to display currency unit based on configuration
factor = get_display_unit_factor(self.config_entry) factor = get_display_unit_factor(self.config_entry)
precision = get_display_precision(self.config_entry)
spread_display = spread * factor spread_display = spread * factor
# Calculate volatility level AND coefficient of variation # Calculate volatility level AND coefficient of variation
@ -116,18 +115,18 @@ class TibberPricesVolatilityCalculator(TibberPricesBaseCalculator):
attrs: dict[str, Any] = { attrs: dict[str, Any] = {
"price_volatility": volatility.lower(), "price_volatility": volatility.lower(),
"price_coefficient_variation_%": round(cv, 2) if cv is not None else None, "price_coefficient_variation_%": round(cv, 2) if cv is not None else None,
"price_spread": round(spread_display, 2), "price_spread": round(spread_display, precision),
"price_min": round(price_min * factor, 2), "price_min": round(price_min * factor, precision),
"price_max": round(price_max * factor, 2), "price_max": round(price_max * factor, precision),
"price_mean": round(price_mean * factor, 2), "price_mean": round(price_mean * factor, precision),
} }
# Add IQR attributes when enough data is available (stay in price_* group) # Add IQR attributes when enough data is available (stay in price_* group)
if iqr_stats is not None: if iqr_stats is not None:
attrs["price_median"] = round(iqr_stats["median"] * factor, 2) attrs["price_median"] = round(iqr_stats["median"] * factor, precision)
attrs["price_q25"] = round(iqr_stats["q25"] * factor, 2) attrs["price_q25"] = round(iqr_stats["q25"] * factor, precision)
attrs["price_q75"] = round(iqr_stats["q75"] * factor, 2) attrs["price_q75"] = round(iqr_stats["q75"] * factor, precision)
attrs["price_typical_spread"] = round(iqr_stats["iqr"] * factor, 2) attrs["price_typical_spread"] = round(iqr_stats["iqr"] * factor, precision)
if iqr_stats["iqr_pct"] is not None: if iqr_stats["iqr_pct"] is not None:
attrs["price_typical_spread_%"] = round(iqr_stats["iqr_pct"], 2) attrs["price_typical_spread_%"] = round(iqr_stats["iqr_pct"], 2)
attrs["price_spike_count"] = iqr_stats["outlier_count"] attrs["price_spike_count"] = iqr_stats["outlier_count"]
@ -208,15 +207,16 @@ class TibberPricesVolatilityCalculator(TibberPricesBaseCalculator):
# Convert to display units for attribute storage # Convert to display units for attribute storage
factor = get_display_unit_factor(self.config_entry) factor = get_display_unit_factor(self.config_entry)
precision = get_display_precision(self.config_entry)
price_attr_key = self._get_subject_price_attr_key(subject) price_attr_key = self._get_subject_price_attr_key(subject)
self._last_percentile_rank_attributes = { self._last_percentile_rank_attributes = {
price_attr_key: round(subject_price * factor, 2), price_attr_key: round(subject_price * factor, precision),
"prices_below_count": bisect.bisect_left(sorted(reference_prices), subject_price), "prices_below_count": bisect.bisect_left(sorted(reference_prices), subject_price),
"interval_count": len(reference_prices), "interval_count": len(reference_prices),
"reference_min": round(min(reference_prices) * factor, 2), "reference_min": round(min(reference_prices) * factor, precision),
"reference_max": round(max(reference_prices) * factor, 2), "reference_max": round(max(reference_prices) * factor, precision),
"reference_mean": round(calculate_mean(reference_prices) * factor, 2), "reference_mean": round(calculate_mean(reference_prices) * factor, precision),
} }
return rank return rank

View file

@ -4,6 +4,7 @@ from __future__ import annotations
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from custom_components.tibber_prices.const import get_display_precision
from custom_components.tibber_prices.entity_utils import get_price_value from custom_components.tibber_prices.entity_utils import get_price_value
from .base import TibberPricesBaseCalculator from .base import TibberPricesBaseCalculator
@ -52,9 +53,10 @@ class TibberPricesWindow24hCalculator(TibberPricesBaseCalculator):
if value is None: if value is None:
return None return None
# Convert to display currency units based on config # Convert to display currency units based on config
mean_result = round(get_price_value(value, config_entry=self.coordinator.config_entry), 2) precision = get_display_precision(self.coordinator.config_entry)
mean_result = round(get_price_value(value, config_entry=self.coordinator.config_entry), precision)
median_result = ( median_result = (
round(get_price_value(median, config_entry=self.coordinator.config_entry), 2) round(get_price_value(median, config_entry=self.coordinator.config_entry), precision)
if median is not None if median is not None
else None else None
) )
@ -65,6 +67,7 @@ class TibberPricesWindow24hCalculator(TibberPricesBaseCalculator):
if value is None: if value is None:
return None return None
# Return in configured display currency units with 2 decimals # Return in configured display currency units
precision = get_display_precision(self.coordinator.config_entry)
result = get_price_value(value, config_entry=self.coordinator.config_entry) result = get_price_value(value, config_entry=self.coordinator.config_entry)
return round(result, 2) return round(result, precision)

View file

@ -2,54 +2,35 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime # noqa: TC003 - Used at runtime for _get_data_timestamp() from datetime import datetime
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
from custom_components.tibber_prices.binary_sensor.attributes import ( from custom_components.tibber_prices.binary_sensor.attributes import get_price_intervals_attributes
get_price_intervals_attributes,
)
from custom_components.tibber_prices.const import ( from custom_components.tibber_prices.const import (
CONF_AVERAGE_SENSOR_DISPLAY, CONF_AVERAGE_SENSOR_DISPLAY,
CONF_CURRENCY_DISPLAY_MODE,
CONF_PRICE_RATING_THRESHOLD_HIGH, CONF_PRICE_RATING_THRESHOLD_HIGH,
CONF_PRICE_RATING_THRESHOLD_LOW, CONF_PRICE_RATING_THRESHOLD_LOW,
DEFAULT_AVERAGE_SENSOR_DISPLAY, DEFAULT_AVERAGE_SENSOR_DISPLAY,
DEFAULT_PRICE_RATING_THRESHOLD_HIGH, DEFAULT_PRICE_RATING_THRESHOLD_HIGH,
DEFAULT_PRICE_RATING_THRESHOLD_LOW, DEFAULT_PRICE_RATING_THRESHOLD_LOW,
DISPLAY_MODE_BASE,
DOMAIN, DOMAIN,
format_price_unit_base, format_price_unit_base,
get_display_precision,
get_display_unit_factor, get_display_unit_factor,
get_display_unit_string, get_display_unit_string,
) )
from custom_components.tibber_prices.coordinator import ( from custom_components.tibber_prices.coordinator import MINUTE_UPDATE_ENTITY_KEYS, TIME_SENSITIVE_ENTITY_KEYS
MINUTE_UPDATE_ENTITY_KEYS, from custom_components.tibber_prices.coordinator.helpers import get_intervals_for_day_offsets
TIME_SENSITIVE_ENTITY_KEYS,
)
from custom_components.tibber_prices.coordinator.helpers import (
get_intervals_for_day_offsets,
)
from custom_components.tibber_prices.entity import TibberPricesEntity from custom_components.tibber_prices.entity import TibberPricesEntity
from custom_components.tibber_prices.entity_utils import ( from custom_components.tibber_prices.entity_utils import (
add_icon_color_attribute, add_icon_color_attribute,
find_rolling_hour_center_index, find_rolling_hour_center_index,
get_price_value, get_price_value,
) )
from custom_components.tibber_prices.entity_utils.icons import ( from custom_components.tibber_prices.entity_utils.icons import TibberPricesIconContext, get_dynamic_icon
TibberPricesIconContext, from custom_components.tibber_prices.utils.average import calculate_next_n_hours_mean
get_dynamic_icon, from custom_components.tibber_prices.utils.price import calculate_volatility_level
) from homeassistant.components.sensor import RestoreSensor, SensorDeviceClass, SensorEntityDescription
from custom_components.tibber_prices.utils.average import (
calculate_next_n_hours_mean,
)
from custom_components.tibber_prices.utils.price import (
calculate_volatility_level,
)
from homeassistant.components.sensor import (
RestoreSensor,
SensorDeviceClass,
SensorEntityDescription,
)
from homeassistant.const import EntityCategory from homeassistant.const import EntityCategory
from homeassistant.core import callback from homeassistant.core import callback
@ -70,11 +51,7 @@ from .calculators import (
TibberPricesVolatilityCalculator, TibberPricesVolatilityCalculator,
TibberPricesWindow24hCalculator, TibberPricesWindow24hCalculator,
) )
from .chart_data import ( from .chart_data import build_chart_data_attributes, call_chartdata_service_async, get_chart_data_state
build_chart_data_attributes,
call_chartdata_service_async,
get_chart_data_state,
)
from .chart_metadata import ( from .chart_metadata import (
build_chart_metadata_attributes, build_chart_metadata_attributes,
call_chartdata_service_for_metadata_async, call_chartdata_service_for_metadata_async,
@ -86,9 +63,7 @@ from .value_getters import get_value_getter_mapping
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import Callable from collections.abc import Callable
from custom_components.tibber_prices.coordinator import ( from custom_components.tibber_prices.coordinator import TibberPricesDataUpdateCoordinator
TibberPricesDataUpdateCoordinator,
)
from custom_components.tibber_prices.coordinator.time_service import TibberPricesTimeService from custom_components.tibber_prices.coordinator.time_service import TibberPricesTimeService
HOURS_IN_DAY = 24 HOURS_IN_DAY = 24
@ -426,6 +401,15 @@ class TibberPricesSensor(TibberPricesEntity, RestoreSensor):
# Coordinator updates bring new API data — always write to ensure fresh state. # Coordinator updates bring new API data — always write to ensure fresh state.
# Reset _last_written_value so timer-based handlers also write next cycle. # Reset _last_written_value so timer-based handlers also write next cycle.
self._last_written_value = _SENTINEL self._last_written_value = _SENTINEL
# Sync suggested display precision with entity registry. HA only updates
# this on entity add and registry entry changes, not on state writes. When
# the user switches currency display mode via options flow, we must push
# the new precision to the registry explicitly. The call is cheap (property
# read + dict comparison) and returns early when values already match.
if self.registry_entry:
self._update_suggested_precision()
super()._handle_coordinator_update() super()._handle_coordinator_update()
def _get_value_getter(self) -> Callable | None: def _get_value_getter(self) -> Callable | None:
@ -581,9 +565,10 @@ class TibberPricesSensor(TibberPricesEntity, RestoreSensor):
self._last_extreme_interval = pi["interval"] self._last_extreme_interval = pi["interval"]
break break
# Return in configured display currency units with 2 decimals # Return in configured display currency units
precision = get_display_precision(self.coordinator.config_entry)
result = get_price_value(value, config_entry=self.coordinator.config_entry) result = get_price_value(value, config_entry=self.coordinator.config_entry)
return round(result, 2) return round(result, precision)
def _get_daily_aggregated_value( def _get_daily_aggregated_value(
self, self,
@ -659,9 +644,10 @@ class TibberPricesSensor(TibberPricesEntity, RestoreSensor):
if value is None: if value is None:
return None return None
# Return in configured display currency units with 2 decimals # Return in configured display currency units
precision = get_display_precision(self.coordinator.config_entry)
result = get_price_value(value, config_entry=self.coordinator.config_entry) result = get_price_value(value, config_entry=self.coordinator.config_entry)
return round(result, 2) return round(result, precision)
def _translate_rating_level(self, level: str) -> str: def _translate_rating_level(self, level: str) -> str:
"""Translate the rating level using custom translations, falling back to English or the raw value.""" """Translate the rating level using custom translations, falling back to English or the raw value."""
@ -710,6 +696,7 @@ class TibberPricesSensor(TibberPricesEntity, RestoreSensor):
# Get display unit factor (100 for minor, 1 for major) # Get display unit factor (100 for minor, 1 for major)
factor = get_display_unit_factor(self.coordinator.config_entry) factor = get_display_unit_factor(self.coordinator.config_entry)
precision = get_display_precision(self.coordinator.config_entry)
# Get user preference for display (mean or median) # Get user preference for display (mean or median)
display_pref = self.coordinator.config_entry.options.get( display_pref = self.coordinator.config_entry.options.get(
@ -717,14 +704,14 @@ class TibberPricesSensor(TibberPricesEntity, RestoreSensor):
) )
# Store both values for attributes # Store both values for attributes
self.cached_data[f"next_avg_{hours}h_mean"] = round(mean_price * factor, 2) self.cached_data[f"next_avg_{hours}h_mean"] = round(mean_price * factor, precision)
if median_price is not None: if median_price is not None:
self.cached_data[f"next_avg_{hours}h_median"] = round(median_price * factor, 2) self.cached_data[f"next_avg_{hours}h_median"] = round(median_price * factor, precision)
# Return the value chosen for state display # Return the value chosen for state display
if display_pref == "median" and median_price is not None: if display_pref == "median" and median_price is not None:
return round(median_price * factor, 2) return round(median_price * factor, precision)
return round(mean_price * factor, 2) # "mean" return round(mean_price * factor, precision) # "mean"
def _get_data_timestamp(self) -> datetime | None: def _get_data_timestamp(self) -> datetime | None:
""" """
@ -916,7 +903,7 @@ class TibberPricesSensor(TibberPricesEntity, RestoreSensor):
return True return True
@property @property
def native_value(self) -> float | str | datetime | None: # noqa: PLR0912 def native_value(self) -> float | str | datetime | None:
"""Return the native value of the sensor.""" """Return the native value of the sensor."""
try: try:
if not self.coordinator.data or not self._value_getter: if not self.coordinator.data or not self._value_getter:
@ -1043,28 +1030,6 @@ class TibberPricesSensor(TibberPricesEntity, RestoreSensor):
key = self.entity_description.key key = self.entity_description.key
value = self.native_value value = self.native_value
# Icon mapping for trend directions (5-level scale)
trend_icons = {
"strongly_rising": "mdi:chevron-double-up",
"rising": "mdi:trending-up",
"stable": "mdi:trending-neutral",
"falling": "mdi:trending-down",
"strongly_falling": "mdi:chevron-double-down",
}
# Special handling for next_price_trend_change: Icon based on direction attribute
if key == "next_price_trend_change":
trend_change_attrs = self._trend_calculator.get_trend_change_attributes()
if trend_change_attrs:
direction = trend_change_attrs.get("direction")
if isinstance(direction, str):
return trend_icons.get(direction, "mdi:help-circle-outline")
return "mdi:help-circle-outline"
# Special handling for current_price_trend: Icon based on current state value
if key == "current_price_trend" and isinstance(value, str):
return trend_icons.get(value, "mdi:help-circle-outline")
# Create callback for period active state check (used by timing sensors) # Create callback for period active state check (used by timing sensors)
period_is_active_callback = None period_is_active_callback = None
if key.startswith("best_price_"): if key.startswith("best_price_"):
@ -1072,6 +1037,13 @@ class TibberPricesSensor(TibberPricesEntity, RestoreSensor):
elif key.startswith("peak_price_"): elif key.startswith("peak_price_"):
period_is_active_callback = self._is_peak_price_period_active period_is_active_callback = self._is_peak_price_period_active
# For next_price_trend_change, pass direction from cached attributes via context
trend_change_direction = None
if key == "next_price_trend_change":
trend_change_attrs = self._trend_calculator.get_trend_change_attributes()
if trend_change_attrs:
trend_change_direction = trend_change_attrs.get("direction")
# Use centralized icon logic with context # Use centralized icon logic with context
icon = get_dynamic_icon( icon = get_dynamic_icon(
key=key, key=key,
@ -1080,24 +1052,37 @@ class TibberPricesSensor(TibberPricesEntity, RestoreSensor):
coordinator_data=self.coordinator.data, coordinator_data=self.coordinator.data,
period_is_active_callback=period_is_active_callback, period_is_active_callback=period_is_active_callback,
time=self.coordinator.time, time=self.coordinator.time,
trend_change_direction=trend_change_direction,
), ),
) )
# Fall back to static icon from entity description # Fall back to static icon from entity description
return icon or self.entity_description.icon return icon or self.entity_description.icon
# Interval price sensors that show individual quarter-hour prices.
# These get full data precision (subunit→2, base→4) as display default
# because users rely on exact values for automations and dashboards.
_INTERVAL_PRICE_KEYS = frozenset(
{
"current_interval_price",
"current_interval_price_base",
"next_interval_price",
"previous_interval_price",
}
)
@property @property
def suggested_display_precision(self) -> int | None: def suggested_display_precision(self) -> int | None:
""" """
Return suggested display precision based on currency display mode. Return suggested display precision based on currency display mode.
For MONETARY sensors: For MONETARY sensors:
- Current/Next Interval Price: Show exact price with higher precision - Interval price sensors: Full precision (subunit2, base4)
- Base currency (/kr): 4 decimals (e.g., 0.1234 ) - Energy Dashboard sensor (current_interval_price_base): Always 4
- Subunit currency (ct/øre): 2 decimals (e.g., 12.34 ct) - All other price sensors: Reduced precision (subunit1, base2)
- All other price sensors:
- Base currency (/kr): 2 decimals (e.g., 0.12 ) The actual state value retains full rounded precision (2 or 4 decimals),
- Subunit currency (ct/øre): 1 decimal (e.g., 12.5 ct) so users can increase display precision in the HA UI to see more detail.
For non-MONETARY sensors, use static value from entity description. For non-MONETARY sensors, use static value from entity description.
""" """
@ -1105,23 +1090,16 @@ class TibberPricesSensor(TibberPricesEntity, RestoreSensor):
if self.entity_description.device_class != SensorDeviceClass.MONETARY: if self.entity_description.device_class != SensorDeviceClass.MONETARY:
return self.entity_description.suggested_display_precision return self.entity_description.suggested_display_precision
# Check display mode configuration # Energy Dashboard sensor: always base currency, always 4 decimals
display_mode = self.coordinator.config_entry.options.get(CONF_CURRENCY_DISPLAY_MODE, DISPLAY_MODE_BASE)
# Special case: Energy Dashboard sensor always shows base currency with 4 decimals
# regardless of display mode (it's always in base currency by design)
if self.entity_description.key == "current_interval_price_base": if self.entity_description.key == "current_interval_price_base":
return 4 return 4
# Special case: Current and Next interval price sensors get higher precision # Interval price sensors: full data precision (subunit→2, base→4)
# to show exact prices as received from API if self.entity_description.key in self._INTERVAL_PRICE_KEYS:
if self.entity_description.key in ("current_interval_price", "next_interval_price"): return get_display_precision(self.coordinator.config_entry)
# Major: 4 decimals (0.1234 €), Minor: 2 decimals (12.34 ct)
return 4 if display_mode == DISPLAY_MODE_BASE else 2
# All other sensors: Standard precision # All other MONETARY sensors: reduced precision (subunit→1, base→2)
# Major: 2 decimals (0.12 €), Minor: 1 decimal (12.5 ct) return get_display_precision(self.coordinator.config_entry) // 2
return 2 if display_mode == DISPLAY_MODE_BASE else 1
@property @property
def extra_state_attributes(self) -> dict[str, Any] | None: def extra_state_attributes(self) -> dict[str, Any] | None:

View file

@ -16,12 +16,9 @@ from __future__ import annotations
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from custom_components.tibber_prices.const import get_display_unit_factor from custom_components.tibber_prices.const import get_display_precision, get_display_unit_factor
from custom_components.tibber_prices.utils.average import calculate_mean, calculate_median from custom_components.tibber_prices.utils.average import calculate_mean, calculate_median
from custom_components.tibber_prices.utils.price import ( from custom_components.tibber_prices.utils.price import aggregate_price_levels, aggregate_price_rating
aggregate_price_levels,
aggregate_price_rating,
)
if TYPE_CHECKING: if TYPE_CHECKING:
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
@ -51,7 +48,8 @@ def aggregate_average_data(
median = calculate_median(prices) median = calculate_median(prices)
# Convert to display currency unit based on configuration # Convert to display currency unit based on configuration
factor = get_display_unit_factor(config_entry) factor = get_display_unit_factor(config_entry)
return round(mean * factor, 2), round(median * factor, 2) if median is not None else None precision = get_display_precision(config_entry)
return round(mean * factor, precision), round(median * factor, precision) if median is not None else None
def aggregate_level_data(window_data: list[dict]) -> str | None: def aggregate_level_data(window_data: list[dict]) -> str | None: