mirror of
https://github.com/jpawlowski/hass.tibber_prices.git
synced 2026-05-28 18:43:40 +00:00
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:
parent
9efa7809d0
commit
406656c9d0
22 changed files with 209 additions and 92 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
|
|
|
|||
Loading…
Reference in a new issue