Add configurable monetary decimal precision

- Add global price_round_decimals option with migration defaults

- Add config number entity to control decimals at runtime

- Apply precision setting across monetary sensor state calculations

- Add translations/custom translations for new number entity

- Fix suggested_display_precision indentation/import issue in sensor core
This commit is contained in:
Sparrow Lara 2026-04-14 12:35:26 +02:00
parent 9efa7809d0
commit 406656c9d0
22 changed files with 209 additions and 92 deletions

View file

@ -21,10 +21,12 @@ from homeassistant.loader import async_get_loaded_integration
from .api import TibberPricesApiClient
from .const import (
CONF_CURRENCY_DISPLAY_MODE,
CONF_PRICE_ROUND_DECIMALS,
CONF_PRICE_TREND_MIN_PRICE_CHANGE,
CONF_PRICE_TREND_MIN_PRICE_CHANGE_STRONGLY,
DATA_CHART_CONFIG,
DATA_CHART_METADATA_CONFIG,
DEFAULT_PRICE_ROUND_DECIMALS,
DISPLAY_MODE_SUBUNIT,
DOMAIN,
LOGGER,
@ -146,6 +148,17 @@ async def _migrate_config_options(hass: HomeAssistant, entry: ConfigEntry) -> No
DISPLAY_MODE_SUBUNIT,
)
# Migration: Ensure monetary sensor rounding precision option exists
if CONF_PRICE_ROUND_DECIMALS not in migrated:
migrated[CONF_PRICE_ROUND_DECIMALS] = DEFAULT_PRICE_ROUND_DECIMALS
migration_performed = True
LOGGER.info(
"[%s] Migrated legacy config: Set %s=%s",
entry.title,
CONF_PRICE_ROUND_DECIMALS,
DEFAULT_PRICE_ROUND_DECIMALS,
)
# Migration: Convert min_price_change from display currency (ct/øre) to base currency (EUR/NOK)
# Before this change, values were stored in display units. Now always stored in base currency.
# Detection: If either value exceeds its new max, both are in old format and need conversion.

View file

@ -323,6 +323,7 @@ def get_currency_name(currency_code: str | None) -> str:
# Configuration key for currency display mode
CONF_CURRENCY_DISPLAY_MODE = "currency_display_mode"
CONF_PRICE_ROUND_DECIMALS = "price_round_decimals"
# Display mode values
DISPLAY_MODE_BASE = "base" # Display in base currency units (€, kr)
@ -341,6 +342,11 @@ DEFAULT_CURRENCY_DISPLAY = {
"GBP": DISPLAY_MODE_BASE,
}
# Display precision (applies to monetary sensor states)
DEFAULT_PRICE_ROUND_DECIMALS = 2
MIN_PRICE_ROUND_DECIMALS = 0
MAX_PRICE_ROUND_DECIMALS = 6
def get_default_currency_display(currency_code: str | None) -> str:
"""
@ -384,6 +390,7 @@ def get_default_options(currency_code: str | None) -> dict[str, Any]:
CONF_EXTENDED_DESCRIPTIONS: DEFAULT_EXTENDED_DESCRIPTIONS,
CONF_AVERAGE_SENSOR_DISPLAY: DEFAULT_AVERAGE_SENSOR_DISPLAY,
CONF_CURRENCY_DISPLAY_MODE: get_default_currency_display(currency_code),
CONF_PRICE_ROUND_DECIMALS: DEFAULT_PRICE_ROUND_DECIMALS,
CONF_VIRTUAL_TIME_OFFSET_DAYS: DEFAULT_VIRTUAL_TIME_OFFSET_DAYS,
CONF_VIRTUAL_TIME_OFFSET_HOURS: DEFAULT_VIRTUAL_TIME_OFFSET_HOURS,
CONF_VIRTUAL_TIME_OFFSET_MINUTES: DEFAULT_VIRTUAL_TIME_OFFSET_MINUTES,
@ -470,6 +477,17 @@ def get_display_unit_factor(config_entry: ConfigEntry) -> int:
return 100 if display_mode == DISPLAY_MODE_SUBUNIT else 1
def get_price_round_decimals(config_entry: ConfigEntry) -> int:
"""Get configured decimal precision for monetary sensor values."""
raw_value = config_entry.options.get(CONF_PRICE_ROUND_DECIMALS, DEFAULT_PRICE_ROUND_DECIMALS)
try:
value = int(raw_value)
except (TypeError, ValueError):
return DEFAULT_PRICE_ROUND_DECIMALS
return max(MIN_PRICE_ROUND_DECIMALS, min(MAX_PRICE_ROUND_DECIMALS, value))
def get_display_unit_string(config_entry: ConfigEntry, currency_code: str | None) -> str:
"""
Get unit string for display based on configuration.

View file

@ -630,6 +630,11 @@
"long_description": "When this entity is enabled, its value overrides the 'Gap Tolerance' setting from the options flow for best price period calculations.",
"usage_tips": "Increase to allow longer periods with occasional price spikes. Keep low for stricter continuous cheap periods."
},
"price_round_decimals": {
"description": "Number of decimals used for monetary sensor state values.",
"long_description": "Applies globally to price-related sensor states (current/next/previous prices, daily min/max/average, and rolling/window averages).",
"usage_tips": "Set to 4 for base-currency precision (for example 0.2534)."
},
"peak_price_flex_override": {
"description": "Maximum below the daily maximum price that intervals can be and still qualify as 'peak price'. Recommended: -15 to -20 with relaxation enabled (default), or -25 to -35 without relaxation. Maximum: -50 (hard cap for reliable period detection). Note: Negative values indicate distance below maximum.",
"long_description": "When this entity is enabled, its value overrides the 'Flexibility' setting from the options flow for peak price period calculations.",

View file

@ -630,6 +630,11 @@
"long_description": "Wanneer deze entiteit is ingeschakeld, overschrijft de waarde de 'Gap tolerantie'-instelling uit de opties-dialoog voor beste prijs-periodeberekeningen.",
"usage_tips": "Verhoog dit voor apparaten met variabele belasting (bijv. warmtepompen) die korte duurdere intervallen kunnen tolereren. Stel in op 0 voor continu goedkope perioden."
},
"price_round_decimals": {
"description": "Aantal decimalen voor monetaire sensorstatuswaarden.",
"long_description": "Wordt globaal toegepast op prijsgerelateerde sensorstatussen (huidig/volgend/vorig, dag min/max/gemiddelde en rolling/window-gemiddelden).",
"usage_tips": "Zet op 4 voor meer precisie in basisvaluta (bijvoorbeeld 0.2534)."
},
"peak_price_flex_override": {
"description": "Maximaal percentage onder de dagelijkse maximumprijs dat intervallen kunnen hebben en nog steeds als 'piekprijs' kwalificeren. Dezelfde aanbevelingen als voor beste prijs-flexibiliteit.",
"long_description": "Wanneer deze entiteit is ingeschakeld, overschrijft de waarde de 'Flexibiliteit'-instelling uit de opties-dialoog voor piekprijs-periodeberekeningen.",

View file

@ -14,7 +14,10 @@ from __future__ import annotations
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_unit_factor,
get_price_round_decimals,
)
if TYPE_CHECKING:
from datetime import datetime
@ -55,7 +58,8 @@ def get_price_value(
# New mode: use config_entry
if config_entry is not None:
factor = get_display_unit_factor(config_entry)
return round(price * factor, 2)
decimals = get_price_round_decimals(config_entry)
return round(price * factor, decimals)
# Fallback: default to subunit currency (backward compatibility)
return round(price * 100, 2)

View file

@ -136,6 +136,11 @@ class TibberPricesConfigNumber(RestoreNumber, NumberEntity):
"""Handle entity which was added to Home Assistant."""
await super().async_added_to_hass()
# Option-backed numbers use config entry as source of truth.
if self.entity_description.store_in_options:
self._attr_native_value = self._get_value_from_options()
return
# Try to restore previous state
last_number_data = await self.async_get_last_number_data()
if last_number_data is not None and last_number_data.native_value is not None:
@ -160,6 +165,10 @@ class TibberPricesConfigNumber(RestoreNumber, NumberEntity):
async def async_will_remove_from_hass(self) -> None:
"""Handle entity removal from Home Assistant."""
if self.entity_description.store_in_options:
await super().async_will_remove_from_hass()
return
# Remove override when entity is removed
self.coordinator.remove_config_override(
self.entity_description.config_key,
@ -170,6 +179,14 @@ class TibberPricesConfigNumber(RestoreNumber, NumberEntity):
def _get_value_from_options(self) -> float:
"""Get the current value from options flow or default."""
options = self.coordinator.config_entry.options
if self.entity_description.store_in_options:
value = options.get(
self.entity_description.config_key,
self.entity_description.default_value,
)
return float(value)
section = options.get(self.entity_description.config_section, {})
value = section.get(
self.entity_description.config_key,
@ -179,6 +196,9 @@ class TibberPricesConfigNumber(RestoreNumber, NumberEntity):
async def _sync_override_state(self) -> None:
"""Sync the override state with the coordinator based on entity enabled state."""
if self.entity_description.store_in_options:
return
# Check if entity is enabled in registry
if self.registry_entry is not None and not self.registry_entry.disabled:
# Entity is enabled - register the override
@ -199,6 +219,18 @@ class TibberPricesConfigNumber(RestoreNumber, NumberEntity):
"""Update the current value and trigger recalculation."""
self._attr_native_value = value
if self.entity_description.store_in_options:
options = dict(self.coordinator.config_entry.options)
options[self.entity_description.config_key] = int(value)
self.hass.config_entries.async_update_entry(self.coordinator.config_entry, options=options)
self.async_write_ha_state()
_LOGGER.debug(
"Updated option-backed %s to %s",
self.entity_description.key,
int(value),
)
return
# Update the coordinator's runtime override
self.coordinator.set_config_override(
self.entity_description.config_key,

View file

@ -32,6 +32,8 @@ class TibberPricesNumberEntityDescription(NumberEntityDescription):
is_peak_price: bool = False
# Default value from const.py
default_value: float | int = 0
# If True, entity writes to flat config_entry.options instead of runtime override sections
store_in_options: bool = False
# ============================================================================
@ -134,6 +136,29 @@ BEST_PRICE_NUMBER_ENTITIES = (
),
)
# ==========================================================================
# DISPLAY SETTINGS OVERRIDES
# ==========================================================================
DISPLAY_NUMBER_ENTITIES = (
TibberPricesNumberEntityDescription(
key="price_round_decimals",
translation_key="price_round_decimals",
icon="mdi:decimal",
entity_category=EntityCategory.CONFIG,
entity_registry_enabled_default=True,
native_min_value=0,
native_max_value=6,
native_step=1,
mode=NumberMode.BOX,
config_key="price_round_decimals",
config_section="",
is_peak_price=False,
default_value=2,
store_in_options=True,
),
)
# ============================================================================
# PEAK PRICE PERIOD CONFIGURATION OVERRIDES
# ============================================================================
@ -235,4 +260,4 @@ PEAK_PRICE_NUMBER_ENTITIES = (
)
# All number entity descriptions combined
NUMBER_ENTITY_DESCRIPTIONS = BEST_PRICE_NUMBER_ENTITIES + PEAK_PRICE_NUMBER_ENTITIES
NUMBER_ENTITY_DESCRIPTIONS = BEST_PRICE_NUMBER_ENTITIES + PEAK_PRICE_NUMBER_ENTITIES + DISPLAY_NUMBER_ENTITIES

View file

@ -7,6 +7,7 @@ from typing import TYPE_CHECKING
from custom_components.tibber_prices.const import (
PRICE_RATING_MAPPING,
get_display_unit_factor,
get_price_round_decimals,
)
from custom_components.tibber_prices.coordinator.helpers import (
get_intervals_for_day_offsets,
@ -30,12 +31,13 @@ def _add_energy_tax_from_interval(
) -> None:
"""Add energy_price and tax from a single interval dict."""
factor = get_display_unit_factor(config_entry)
decimals = get_price_round_decimals(config_entry)
energy = interval_data.get("energy")
if energy is not None:
attributes["energy_price"] = round(float(energy) * factor, 2)
attributes["energy_price"] = round(float(energy) * factor, decimals)
tax = interval_data.get("tax")
if tax is not None:
attributes["tax"] = round(float(tax) * factor, 2)
attributes["tax"] = round(float(tax) * factor, decimals)
def _add_energy_tax_averages_from_cache(
@ -49,14 +51,15 @@ def _add_energy_tax_averages_from_cache(
"last_energy_tax_averages", (None, None, None, None)
)
factor = get_display_unit_factor(config_entry)
decimals = get_price_round_decimals(config_entry)
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, decimals)
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, decimals)
if tax_mean is not None:
attributes["tax_mean"] = round(float(tax_mean) * factor, 2)
attributes["tax_mean"] = round(float(tax_mean) * factor, decimals)
if tax_median is not None:
attributes["tax_median"] = round(float(tax_median) * factor, 2)
attributes["tax_median"] = round(float(tax_median) * factor, decimals)
def _get_day_midnight_timestamp(key: str, *, time: TibberPricesTimeService) -> datetime:

View file

@ -4,7 +4,10 @@ from __future__ import annotations
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_unit_factor,
get_price_round_decimals,
)
from custom_components.tibber_prices.coordinator.helpers import get_intervals_for_day_offsets
if TYPE_CHECKING:
@ -142,7 +145,8 @@ def get_future_prices(
# Convert to display currency unit based on configuration
price_major = float(price_data["total"])
factor = get_display_unit_factor(config_entry)
price_display = round(price_major * factor, 2)
decimals = get_price_round_decimals(config_entry)
price_display = round(price_major * factor, decimals)
future_prices.append(
{

View file

@ -9,6 +9,7 @@ from custom_components.tibber_prices.const import (
PRICE_LEVEL_MAPPING,
PRICE_RATING_MAPPING,
get_display_unit_factor,
get_price_round_decimals,
)
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
@ -112,14 +113,15 @@ def _add_energy_tax_attributes(
return
factor = get_display_unit_factor(config_entry)
decimals = get_price_round_decimals(config_entry)
energy = interval_data.get("energy")
if energy is not None:
attributes["energy_price"] = round(float(energy) * factor, 2)
attributes["energy_price"] = round(float(energy) * factor, decimals)
tax = interval_data.get("tax")
if tax is not None:
attributes["tax"] = round(float(tax) * factor, 2)
attributes["tax"] = round(float(tax) * factor, decimals)
def add_current_interval_price_attributes( # noqa: PLR0913

View file

@ -115,9 +115,9 @@ class TibberPricesDailyStatCalculator(TibberPricesBaseCalculator):
# Compute and cache energy/tax averages for attribute builders
self._cache_energy_tax_averages(price_intervals)
# Convert to display currency units based on config
avg_result = round(get_price_value(value, config_entry=self.coordinator.config_entry), 2)
avg_result = get_price_value(value, config_entry=self.coordinator.config_entry)
median_result = (
round(get_price_value(median, config_entry=self.coordinator.config_entry), 2)
get_price_value(median, config_entry=self.coordinator.config_entry)
if median is not None
else None
)
@ -132,9 +132,8 @@ class TibberPricesDailyStatCalculator(TibberPricesBaseCalculator):
self._last_extreme_interval = pi["interval"]
break
# Return in configured display currency units with 2 decimals
result = get_price_value(value, config_entry=self.coordinator.config_entry)
return round(result, 2)
# Return in configured display currency units with configured precision
return get_price_value(value, config_entry=self.coordinator.config_entry)
def get_daily_aggregated_value(
self,

View file

@ -4,7 +4,10 @@ from __future__ import annotations
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_unit_factor,
get_price_round_decimals,
)
from .base import TibberPricesBaseCalculator
@ -74,7 +77,8 @@ class TibberPricesIntervalCalculator(TibberPricesBaseCalculator):
if in_euro:
return price
factor = get_display_unit_factor(self.config_entry)
return round(price * factor, 2)
decimals = get_price_round_decimals(self.config_entry)
return round(price * factor, decimals)
if value_type == "level":
level = self.safe_get_from_interval(interval_data, "level")

View file

@ -15,7 +15,10 @@ Caching strategy:
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_unit_factor,
get_price_round_decimals,
)
from custom_components.tibber_prices.coordinator.helpers import get_intervals_for_day_offsets
from custom_components.tibber_prices.utils.average import calculate_mean, calculate_next_n_hours_mean
from custom_components.tibber_prices.utils.price import (
@ -180,6 +183,7 @@ class TibberPricesTrendCalculator(TibberPricesBaseCalculator):
# Convert prices to display currency unit based on configuration
factor = get_display_unit_factor(self.config_entry)
decimals = get_price_round_decimals(self.config_entry)
# Store attributes in sensor-specific dictionary AND cache the trend value
# Show effective thresholds (after volatility adjustment) so users can understand
@ -188,7 +192,7 @@ class TibberPricesTrendCalculator(TibberPricesBaseCalculator):
"timestamp": next_interval_start,
"trend_value": trend_value,
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, decimals),
"interval_count": lookahead_intervals,
"threshold_rising_%": round(threshold_rising * vol_factor, 1),
"threshold_rising_strongly_%": round(threshold_strongly_rising * vol_factor, 1),
@ -203,7 +207,7 @@ class TibberPricesTrendCalculator(TibberPricesBaseCalculator):
# Get second half average for longer periods
later_half_avg = self._calculate_later_half_average(hours, next_interval_start)
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, decimals)
# 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
@ -378,6 +382,7 @@ class TibberPricesTrendCalculator(TibberPricesBaseCalculator):
)
factor = get_display_unit_factor(self.config_entry)
decimals = get_price_round_decimals(self.config_entry)
time_obj = self.coordinator.time
total_intervals = time_obj.minutes_to_intervals(hours * 60)
first_half_count = total_intervals // 2
@ -387,8 +392,8 @@ class TibberPricesTrendCalculator(TibberPricesBaseCalculator):
"timestamp": next_interval_start,
"trend_value": trend_value,
f"trajectory_{hours}h_%": round(diff_pct, 1),
f"first_half_{hours}h_avg": round(first_half_avg * factor, 2),
f"second_half_{hours}h_avg": round(second_half_avg * factor, 2),
f"first_half_{hours}h_avg": round(first_half_avg * factor, decimals),
f"second_half_{hours}h_avg": round(second_half_avg * factor, decimals),
f"first_half_{hours}h_diff_from_current_%": round(
((first_half_avg - current_interval_price) / abs(current_interval_price)) * 100, 1
)
@ -910,16 +915,19 @@ class TibberPricesTrendCalculator(TibberPricesBaseCalculator):
change_price = float(change_interval["total"])
minutes_until = time.minutes_until_rounded(change_time)
factor = get_display_unit_factor(self.config_entry)
decimals = get_price_round_decimals(self.config_entry)
vf = first_change["vol_factor"]
self._trend_change_attributes = {
"direction": first_change["trend"],
"from_direction": current_trend_state,
"minutes_until_change": minutes_until,
"price_now": round(float(current_interval["total"]) * factor, 2),
"price_at_change": round(change_price * factor, 2),
"price_now": round(float(current_interval["total"]) * factor, decimals),
"price_at_change": round(change_price * factor, decimals),
"price_avg_after_change": (
round(first_change["mean"] * factor, 2) if first_change["mean"] else None
round(first_change["mean"] * factor, decimals)
if first_change["mean"]
else None
),
"trend_diff_%": round(first_change["diff"], 1),
"threshold_rising_%": round(thresholds["rising"] * vf, 1),

View file

@ -13,6 +13,7 @@ from custom_components.tibber_prices.const import (
DEFAULT_VOLATILITY_THRESHOLD_MODERATE,
DEFAULT_VOLATILITY_THRESHOLD_VERY_HIGH,
get_display_unit_factor,
get_price_round_decimals,
)
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
@ -103,6 +104,7 @@ class TibberPricesVolatilityCalculator(TibberPricesBaseCalculator):
# Convert to display currency unit based on configuration
factor = get_display_unit_factor(self.config_entry)
decimals = get_price_round_decimals(self.config_entry)
spread_display = spread * factor
# Calculate volatility level AND coefficient of variation
@ -116,18 +118,18 @@ class TibberPricesVolatilityCalculator(TibberPricesBaseCalculator):
attrs: dict[str, Any] = {
"price_volatility": volatility.lower(),
"price_coefficient_variation_%": round(cv, 2) if cv is not None else None,
"price_spread": round(spread_display, 2),
"price_min": round(price_min * factor, 2),
"price_max": round(price_max * factor, 2),
"price_mean": round(price_mean * factor, 2),
"price_spread": round(spread_display, decimals),
"price_min": round(price_min * factor, decimals),
"price_max": round(price_max * factor, decimals),
"price_mean": round(price_mean * factor, decimals),
}
# Add IQR attributes when enough data is available (stay in price_* group)
if iqr_stats is not None:
attrs["price_median"] = round(iqr_stats["median"] * factor, 2)
attrs["price_q25"] = round(iqr_stats["q25"] * factor, 2)
attrs["price_q75"] = round(iqr_stats["q75"] * factor, 2)
attrs["price_typical_spread"] = round(iqr_stats["iqr"] * factor, 2)
attrs["price_median"] = round(iqr_stats["median"] * factor, decimals)
attrs["price_q25"] = round(iqr_stats["q25"] * factor, decimals)
attrs["price_q75"] = round(iqr_stats["q75"] * factor, decimals)
attrs["price_typical_spread"] = round(iqr_stats["iqr"] * factor, decimals)
if iqr_stats["iqr_pct"] is not None:
attrs["price_typical_spread_%"] = round(iqr_stats["iqr_pct"], 2)
attrs["price_spike_count"] = iqr_stats["outlier_count"]
@ -208,15 +210,16 @@ class TibberPricesVolatilityCalculator(TibberPricesBaseCalculator):
# Convert to display units for attribute storage
factor = get_display_unit_factor(self.config_entry)
decimals = get_price_round_decimals(self.config_entry)
price_attr_key = self._get_subject_price_attr_key(subject)
self._last_percentile_rank_attributes = {
price_attr_key: round(subject_price * factor, 2),
price_attr_key: round(subject_price * factor, decimals),
"prices_below_count": bisect.bisect_left(sorted(reference_prices), subject_price),
"interval_count": len(reference_prices),
"reference_min": round(min(reference_prices) * factor, 2),
"reference_max": round(max(reference_prices) * factor, 2),
"reference_mean": round(calculate_mean(reference_prices) * factor, 2),
"reference_min": round(min(reference_prices) * factor, decimals),
"reference_max": round(max(reference_prices) * factor, decimals),
"reference_mean": round(calculate_mean(reference_prices) * factor, decimals),
}
return rank

View file

@ -52,9 +52,9 @@ class TibberPricesWindow24hCalculator(TibberPricesBaseCalculator):
if value is None:
return None
# Convert to display currency units based on config
mean_result = round(get_price_value(value, config_entry=self.coordinator.config_entry), 2)
mean_result = get_price_value(value, config_entry=self.coordinator.config_entry)
median_result = (
round(get_price_value(median, config_entry=self.coordinator.config_entry), 2)
get_price_value(median, config_entry=self.coordinator.config_entry)
if median is not None
else None
)
@ -65,6 +65,5 @@ class TibberPricesWindow24hCalculator(TibberPricesBaseCalculator):
if value is None:
return None
# Return in configured display currency units with 2 decimals
result = get_price_value(value, config_entry=self.coordinator.config_entry)
return round(result, 2)
# Return in configured display currency units with configured precision
return get_price_value(value, config_entry=self.coordinator.config_entry)

View file

@ -10,17 +10,16 @@ from custom_components.tibber_prices.binary_sensor.attributes import (
)
from custom_components.tibber_prices.const import (
CONF_AVERAGE_SENSOR_DISPLAY,
CONF_CURRENCY_DISPLAY_MODE,
CONF_PRICE_RATING_THRESHOLD_HIGH,
CONF_PRICE_RATING_THRESHOLD_LOW,
DEFAULT_AVERAGE_SENSOR_DISPLAY,
DEFAULT_PRICE_RATING_THRESHOLD_HIGH,
DEFAULT_PRICE_RATING_THRESHOLD_LOW,
DISPLAY_MODE_BASE,
DOMAIN,
format_price_unit_base,
get_display_unit_factor,
get_display_unit_string,
get_price_round_decimals,
)
from custom_components.tibber_prices.coordinator import (
MINUTE_UPDATE_ENTITY_KEYS,
@ -581,9 +580,8 @@ class TibberPricesSensor(TibberPricesEntity, RestoreSensor):
self._last_extreme_interval = pi["interval"]
break
# Return in configured display currency units with 2 decimals
result = get_price_value(value, config_entry=self.coordinator.config_entry)
return round(result, 2)
# Return in configured display currency units with configured precision
return get_price_value(value, config_entry=self.coordinator.config_entry)
def _get_daily_aggregated_value(
self,
@ -659,9 +657,8 @@ class TibberPricesSensor(TibberPricesEntity, RestoreSensor):
if value is None:
return None
# Return in configured display currency units with 2 decimals
result = get_price_value(value, config_entry=self.coordinator.config_entry)
return round(result, 2)
# Return in configured display currency units with configured precision
return get_price_value(value, config_entry=self.coordinator.config_entry)
def _translate_rating_level(self, level: str) -> str:
"""Translate the rating level using custom translations, falling back to English or the raw value."""
@ -710,6 +707,7 @@ class TibberPricesSensor(TibberPricesEntity, RestoreSensor):
# Get display unit factor (100 for minor, 1 for major)
factor = get_display_unit_factor(self.coordinator.config_entry)
decimals = get_price_round_decimals(self.coordinator.config_entry)
# Get user preference for display (mean or median)
display_pref = self.coordinator.config_entry.options.get(
@ -717,14 +715,14 @@ class TibberPricesSensor(TibberPricesEntity, RestoreSensor):
)
# 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, decimals)
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, decimals)
# Return the value chosen for state display
if display_pref == "median" and median_price is not None:
return round(median_price * factor, 2)
return round(mean_price * factor, 2) # "mean"
return round(median_price * factor, decimals)
return round(mean_price * factor, decimals) # "mean"
def _get_data_timestamp(self) -> datetime | None:
"""
@ -1089,39 +1087,15 @@ class TibberPricesSensor(TibberPricesEntity, RestoreSensor):
@property
def suggested_display_precision(self) -> int | None:
"""
Return suggested display precision based on currency display mode.
Return suggested display precision.
For MONETARY sensors:
- Current/Next Interval Price: Show exact price with higher precision
- Base currency (/kr): 4 decimals (e.g., 0.1234 )
- Subunit currency (ct/øre): 2 decimals (e.g., 12.34 ct)
- All other price sensors:
- Base currency (/kr): 2 decimals (e.g., 0.12 )
- Subunit currency (ct/øre): 1 decimal (e.g., 12.5 ct)
For non-MONETARY sensors, use static value from entity description.
Monetary sensors follow the global user-configured rounding precision.
Non-monetary sensors keep their static precision from entity description.
"""
# Only apply dynamic precision to MONETARY sensors
if self.entity_description.device_class != SensorDeviceClass.MONETARY:
return self.entity_description.suggested_display_precision
# Check display mode configuration
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":
return 4
# Special case: Current and Next interval price sensors get higher precision
# to show exact prices as received from API
if self.entity_description.key in ("current_interval_price", "next_interval_price"):
# 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
# Major: 2 decimals (0.12 €), Minor: 1 decimal (12.5 ct)
return 2 if display_mode == DISPLAY_MODE_BASE else 1
return get_price_round_decimals(self.coordinator.config_entry)
@property
def extra_state_attributes(self) -> dict[str, Any] | None:

View file

@ -16,7 +16,10 @@ from __future__ import annotations
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_unit_factor,
get_price_round_decimals,
)
from custom_components.tibber_prices.utils.average import calculate_mean, calculate_median
from custom_components.tibber_prices.utils.price import (
aggregate_price_levels,
@ -51,7 +54,8 @@ def aggregate_average_data(
median = calculate_median(prices)
# Convert to display currency unit based on configuration
factor = get_display_unit_factor(config_entry)
return round(mean * factor, 2), round(median * factor, 2) if median is not None else None
decimals = get_price_round_decimals(config_entry)
return round(mean * factor, decimals), round(median * factor, decimals) if median is not None else None
def aggregate_level_data(window_data: list[dict]) -> str | None:

View file

@ -1101,6 +1101,9 @@
"best_price_gap_count_override": {
"name": "Bestpreis: Lückentoleranz"
},
"price_round_decimals": {
"name": "Preis: Dezimalstellen"
},
"peak_price_flex_override": {
"name": "Spitzenpreis: Flexibilität"
},
@ -1208,7 +1211,7 @@
"message": "array_fields kann nur mit output_format: array_of_arrays verwendet werden. Ändere das Ausgabeformat oder entferne array_fields."
},
"invalid_array_fields": {
"message": "Der Wert {template} für array_fields ist ungültig. Feldnamen müssen in geschweifte Klammern eingeschlossen sein, z.B. '{start_time}, {price_per_kwh}, {level}'."
"message": "Der Wert '{template}' für array_fields ist ungültig. Feldnamen müssen in geschweifte Klammern eingeschlossen sein, z.B. '{start_time}, {price_per_kwh}, {level}'."
},
"min_segment_exceeds_duration": {
"message": "min_segment_duration ({min_segment_minutes} Min.) darf die Gesamtdauer ({duration_minutes} Min.) nicht überschreiten. Reduziere min_segment_duration oder erhöhe duration."

View file

@ -1101,6 +1101,9 @@
"best_price_gap_count_override": {
"name": "Best Price: Gap Tolerance"
},
"price_round_decimals": {
"name": "Price: Decimal Places"
},
"peak_price_flex_override": {
"name": "Peak Price: Flexibility"
},
@ -1208,7 +1211,7 @@
"message": "array_fields can only be used with output_format: array_of_arrays. Change the output format or remove array_fields."
},
"invalid_array_fields": {
"message": "The array_fields value {template} is invalid. Field names must be wrapped in curly braces, e.g. '{start_time}, {price_per_kwh}, {level}'."
"message": "The array_fields value '{template}' is invalid. Field names must be wrapped in curly braces, e.g. '{start_time}, {price_per_kwh}, {level}'."
},
"min_segment_exceeds_duration": {
"message": "min_segment_duration ({min_segment_minutes} min) cannot exceed the total duration ({duration_minutes} min). Reduce min_segment_duration or increase duration."

View file

@ -1101,6 +1101,9 @@
"best_price_gap_count_override": {
"name": "Beste pris: Gaptoleranse"
},
"price_round_decimals": {
"name": "Pris: Desimaler"
},
"peak_price_flex_override": {
"name": "Topppris: Fleksibilitet"
},
@ -1208,7 +1211,7 @@
"message": "array_fields kan kun brukes med output_format: array_of_arrays. Endre utdataformatet eller fjern array_fields."
},
"invalid_array_fields": {
"message": "Verdien {template} for array_fields er ugyldig. Feltnavn må omsluttes av krøllparenteser, f.eks. '{start_time}, {price_per_kwh}, {level}'."
"message": "Verdien '{template}' for array_fields er ugyldig. Feltnavn må omsluttes av krøllparenteser, f.eks. '{start_time}, {price_per_kwh}, {level}'."
},
"min_segment_exceeds_duration": {
"message": "min_segment_duration ({min_segment_minutes} min) kan ikke overstige total varighet ({duration_minutes} min). Reduser min_segment_duration eller øk duration."

View file

@ -1101,6 +1101,9 @@
"best_price_gap_count_override": {
"name": "Beste prijs: Gap tolerantie"
},
"price_round_decimals": {
"name": "Prijs: Decimalen"
},
"peak_price_flex_override": {
"name": "Piekprijs: Flexibiliteit"
},
@ -1208,7 +1211,7 @@
"message": "array_fields kan alleen gebruikt worden met output_format: array_of_arrays. Wijzig het uitvoerformaat of verwijder array_fields."
},
"invalid_array_fields": {
"message": "De waarde {template} voor array_fields is ongeldig. Veldnamen moeten tussen accolades staan, bijv. '{start_time}, {price_per_kwh}, {level}'."
"message": "De waarde '{template}' voor array_fields is ongeldig. Veldnamen moeten tussen accolades staan, bijv. '{start_time}, {price_per_kwh}, {level}'."
},
"min_segment_exceeds_duration": {
"message": "min_segment_duration ({min_segment_minutes} min) mag de totale duur ({duration_minutes} min) niet overschrijden. Verklein min_segment_duration of vergroot duration."

View file

@ -1101,6 +1101,9 @@
"best_price_gap_count_override": {
"name": "Bästa pris: Glaptolerans"
},
"price_round_decimals": {
"name": "Pris: Decimaler"
},
"peak_price_flex_override": {
"name": "Topppris: Flexibilitet"
},
@ -1208,7 +1211,7 @@
"message": "array_fields kan bara användas med output_format: array_of_arrays. Ändra utdataformatet eller ta bort array_fields."
},
"invalid_array_fields": {
"message": "Värdet {template} för array_fields är ogiltigt. Fältnamn måste omges av klammerparenteser, t.ex. '{start_time}, {price_per_kwh}, {level}'."
"message": "Värdet '{template}' för array_fields är ogiltigt. Fältnamn måste omges av klammerparenteser, t.ex. '{start_time}, {price_per_kwh}, {level}'."
},
"min_segment_exceeds_duration": {
"message": "min_segment_duration ({min_segment_minutes} min) får inte överstiga den totala varaktigheten ({duration_minutes} min). Minska min_segment_duration eller öka duration."