mirror of
https://github.com/jpawlowski/hass.tibber_prices.git
synced 2026-05-28 18:43:40 +00:00
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:
parent
6d22ea7151
commit
a4ad506e01
13 changed files with 208 additions and 207 deletions
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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 (subunit→2, base→4)
|
||||||
- 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 (subunit→1, base→2)
|
||||||
- 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:
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue