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 .api import TibberPricesApiClient
from .const import ( from .const import (
CONF_CURRENCY_DISPLAY_MODE, CONF_CURRENCY_DISPLAY_MODE,
CONF_PRICE_ROUND_DECIMALS,
CONF_PRICE_TREND_MIN_PRICE_CHANGE, CONF_PRICE_TREND_MIN_PRICE_CHANGE,
CONF_PRICE_TREND_MIN_PRICE_CHANGE_STRONGLY, CONF_PRICE_TREND_MIN_PRICE_CHANGE_STRONGLY,
DATA_CHART_CONFIG, DATA_CHART_CONFIG,
DATA_CHART_METADATA_CONFIG, DATA_CHART_METADATA_CONFIG,
DEFAULT_PRICE_ROUND_DECIMALS,
DISPLAY_MODE_SUBUNIT, DISPLAY_MODE_SUBUNIT,
DOMAIN, DOMAIN,
LOGGER, LOGGER,
@ -146,6 +148,17 @@ async def _migrate_config_options(hass: HomeAssistant, entry: ConfigEntry) -> No
DISPLAY_MODE_SUBUNIT, 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) # 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. # 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. # 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 # Configuration key for currency display mode
CONF_CURRENCY_DISPLAY_MODE = "currency_display_mode" CONF_CURRENCY_DISPLAY_MODE = "currency_display_mode"
CONF_PRICE_ROUND_DECIMALS = "price_round_decimals"
# Display mode values # Display mode values
DISPLAY_MODE_BASE = "base" # Display in base currency units (€, kr) DISPLAY_MODE_BASE = "base" # Display in base currency units (€, kr)
@ -341,6 +342,11 @@ DEFAULT_CURRENCY_DISPLAY = {
"GBP": DISPLAY_MODE_BASE, "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: 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_EXTENDED_DESCRIPTIONS: DEFAULT_EXTENDED_DESCRIPTIONS,
CONF_AVERAGE_SENSOR_DISPLAY: DEFAULT_AVERAGE_SENSOR_DISPLAY, CONF_AVERAGE_SENSOR_DISPLAY: DEFAULT_AVERAGE_SENSOR_DISPLAY,
CONF_CURRENCY_DISPLAY_MODE: get_default_currency_display(currency_code), 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_DAYS: DEFAULT_VIRTUAL_TIME_OFFSET_DAYS,
CONF_VIRTUAL_TIME_OFFSET_HOURS: DEFAULT_VIRTUAL_TIME_OFFSET_HOURS, CONF_VIRTUAL_TIME_OFFSET_HOURS: DEFAULT_VIRTUAL_TIME_OFFSET_HOURS,
CONF_VIRTUAL_TIME_OFFSET_MINUTES: DEFAULT_VIRTUAL_TIME_OFFSET_MINUTES, 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 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: def get_display_unit_string(config_entry: ConfigEntry, currency_code: str | None) -> str:
""" """
Get unit string for display based on configuration. Get unit string for display based on configuration.

View file

@ -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.", "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." "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": { "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.", "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.", "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.", "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." "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": { "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.", "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.", "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 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: if TYPE_CHECKING:
from datetime import datetime from datetime import datetime
@ -55,7 +58,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) decimals = get_price_round_decimals(config_entry)
return round(price * factor, decimals)
# Fallback: default to subunit currency (backward compatibility) # Fallback: default to subunit currency (backward compatibility)
return round(price * 100, 2) return round(price * 100, 2)

View file

@ -136,6 +136,11 @@ class TibberPricesConfigNumber(RestoreNumber, NumberEntity):
"""Handle entity which was added to Home Assistant.""" """Handle entity which was added to Home Assistant."""
await super().async_added_to_hass() 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 # Try to restore previous state
last_number_data = await self.async_get_last_number_data() 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: 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: async def async_will_remove_from_hass(self) -> None:
"""Handle entity removal from Home Assistant.""" """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 # Remove override when entity is removed
self.coordinator.remove_config_override( self.coordinator.remove_config_override(
self.entity_description.config_key, self.entity_description.config_key,
@ -170,6 +179,14 @@ class TibberPricesConfigNumber(RestoreNumber, NumberEntity):
def _get_value_from_options(self) -> float: def _get_value_from_options(self) -> float:
"""Get the current value from options flow or default.""" """Get the current value from options flow or default."""
options = self.coordinator.config_entry.options 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, {}) section = options.get(self.entity_description.config_section, {})
value = section.get( value = section.get(
self.entity_description.config_key, self.entity_description.config_key,
@ -179,6 +196,9 @@ class TibberPricesConfigNumber(RestoreNumber, NumberEntity):
async def _sync_override_state(self) -> None: async def _sync_override_state(self) -> None:
"""Sync the override state with the coordinator based on entity enabled state.""" """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 # Check if entity is enabled in registry
if self.registry_entry is not None and not self.registry_entry.disabled: if self.registry_entry is not None and not self.registry_entry.disabled:
# Entity is enabled - register the override # Entity is enabled - register the override
@ -199,6 +219,18 @@ class TibberPricesConfigNumber(RestoreNumber, NumberEntity):
"""Update the current value and trigger recalculation.""" """Update the current value and trigger recalculation."""
self._attr_native_value = value 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 # Update the coordinator's runtime override
self.coordinator.set_config_override( self.coordinator.set_config_override(
self.entity_description.config_key, self.entity_description.config_key,

View file

@ -32,6 +32,8 @@ class TibberPricesNumberEntityDescription(NumberEntityDescription):
is_peak_price: bool = False is_peak_price: bool = False
# Default value from const.py # Default value from const.py
default_value: float | int = 0 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 # PEAK PRICE PERIOD CONFIGURATION OVERRIDES
# ============================================================================ # ============================================================================
@ -235,4 +260,4 @@ PEAK_PRICE_NUMBER_ENTITIES = (
) )
# All number entity descriptions combined # 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 ( from custom_components.tibber_prices.const import (
PRICE_RATING_MAPPING, PRICE_RATING_MAPPING,
get_display_unit_factor, get_display_unit_factor,
get_price_round_decimals,
) )
from custom_components.tibber_prices.coordinator.helpers import ( from custom_components.tibber_prices.coordinator.helpers import (
get_intervals_for_day_offsets, get_intervals_for_day_offsets,
@ -30,12 +31,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)
decimals = get_price_round_decimals(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, decimals)
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, decimals)
def _add_energy_tax_averages_from_cache( 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) "last_energy_tax_averages", (None, None, None, None)
) )
factor = get_display_unit_factor(config_entry) factor = get_display_unit_factor(config_entry)
decimals = get_price_round_decimals(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, decimals)
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, decimals)
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, decimals)
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, decimals)
def _get_day_midnight_timestamp(key: str, *, time: TibberPricesTimeService) -> datetime: 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 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 from custom_components.tibber_prices.coordinator.helpers import get_intervals_for_day_offsets
if TYPE_CHECKING: if TYPE_CHECKING:
@ -142,7 +145,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) decimals = get_price_round_decimals(config_entry)
price_display = round(price_major * factor, decimals)
future_prices.append( future_prices.append(
{ {

View file

@ -9,6 +9,7 @@ from custom_components.tibber_prices.const import (
PRICE_LEVEL_MAPPING, PRICE_LEVEL_MAPPING,
PRICE_RATING_MAPPING, PRICE_RATING_MAPPING,
get_display_unit_factor, 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.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
@ -112,14 +113,15 @@ def _add_energy_tax_attributes(
return return
factor = get_display_unit_factor(config_entry) factor = get_display_unit_factor(config_entry)
decimals = get_price_round_decimals(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, decimals)
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, decimals)
def add_current_interval_price_attributes( # noqa: PLR0913 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 # 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) avg_result = get_price_value(value, config_entry=self.coordinator.config_entry)
median_result = ( 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 if median is not None
else None else None
) )
@ -132,9 +132,8 @@ 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 with configured precision
result = get_price_value(value, config_entry=self.coordinator.config_entry) return get_price_value(value, config_entry=self.coordinator.config_entry)
return round(result, 2)
def get_daily_aggregated_value( def get_daily_aggregated_value(
self, self,

View file

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

View file

@ -15,7 +15,10 @@ 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_unit_factor,
get_price_round_decimals,
)
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.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 (
@ -180,6 +183,7 @@ class TibberPricesTrendCalculator(TibberPricesBaseCalculator):
# 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)
decimals = get_price_round_decimals(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 +192,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, decimals),
"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 +207,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, decimals)
# 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
@ -378,6 +382,7 @@ class TibberPricesTrendCalculator(TibberPricesBaseCalculator):
) )
factor = get_display_unit_factor(self.config_entry) factor = get_display_unit_factor(self.config_entry)
decimals = get_price_round_decimals(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 +392,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, decimals),
f"second_half_{hours}h_avg": round(second_half_avg * factor, 2), f"second_half_{hours}h_avg": round(second_half_avg * factor, decimals),
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
) )
@ -910,16 +915,19 @@ 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)
decimals = get_price_round_decimals(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, decimals),
"price_at_change": round(change_price * factor, 2), "price_at_change": round(change_price * factor, decimals),
"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, decimals)
if first_change["mean"]
else None
), ),
"trend_diff_%": round(first_change["diff"], 1), "trend_diff_%": round(first_change["diff"], 1),
"threshold_rising_%": round(thresholds["rising"] * vf, 1), "threshold_rising_%": round(thresholds["rising"] * vf, 1),

View file

@ -13,6 +13,7 @@ from custom_components.tibber_prices.const import (
DEFAULT_VOLATILITY_THRESHOLD_MODERATE, DEFAULT_VOLATILITY_THRESHOLD_MODERATE,
DEFAULT_VOLATILITY_THRESHOLD_VERY_HIGH, DEFAULT_VOLATILITY_THRESHOLD_VERY_HIGH,
get_display_unit_factor, 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.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
@ -103,6 +104,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)
decimals = get_price_round_decimals(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 +118,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, decimals),
"price_min": round(price_min * factor, 2), "price_min": round(price_min * factor, decimals),
"price_max": round(price_max * factor, 2), "price_max": round(price_max * factor, decimals),
"price_mean": round(price_mean * factor, 2), "price_mean": round(price_mean * factor, decimals),
} }
# 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, decimals)
attrs["price_q25"] = round(iqr_stats["q25"] * factor, 2) attrs["price_q25"] = round(iqr_stats["q25"] * factor, decimals)
attrs["price_q75"] = round(iqr_stats["q75"] * factor, 2) attrs["price_q75"] = round(iqr_stats["q75"] * factor, decimals)
attrs["price_typical_spread"] = round(iqr_stats["iqr"] * factor, 2) attrs["price_typical_spread"] = round(iqr_stats["iqr"] * factor, decimals)
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 +210,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)
decimals = get_price_round_decimals(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, decimals),
"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, decimals),
"reference_max": round(max(reference_prices) * factor, 2), "reference_max": round(max(reference_prices) * factor, decimals),
"reference_mean": round(calculate_mean(reference_prices) * factor, 2), "reference_mean": round(calculate_mean(reference_prices) * factor, decimals),
} }
return rank return rank

View file

@ -52,9 +52,9 @@ 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) mean_result = get_price_value(value, config_entry=self.coordinator.config_entry)
median_result = ( 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 if median is not None
else None else None
) )
@ -65,6 +65,5 @@ 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 with configured precision
result = get_price_value(value, config_entry=self.coordinator.config_entry) return get_price_value(value, config_entry=self.coordinator.config_entry)
return round(result, 2)

View file

@ -10,17 +10,16 @@ from custom_components.tibber_prices.binary_sensor.attributes import (
) )
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_unit_factor, get_display_unit_factor,
get_display_unit_string, get_display_unit_string,
get_price_round_decimals,
) )
from custom_components.tibber_prices.coordinator import ( from custom_components.tibber_prices.coordinator import (
MINUTE_UPDATE_ENTITY_KEYS, MINUTE_UPDATE_ENTITY_KEYS,
@ -581,9 +580,8 @@ 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 with configured precision
result = get_price_value(value, config_entry=self.coordinator.config_entry) return get_price_value(value, config_entry=self.coordinator.config_entry)
return round(result, 2)
def _get_daily_aggregated_value( def _get_daily_aggregated_value(
self, self,
@ -659,9 +657,8 @@ 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 with configured precision
result = get_price_value(value, config_entry=self.coordinator.config_entry) return get_price_value(value, config_entry=self.coordinator.config_entry)
return round(result, 2)
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 +707,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)
decimals = get_price_round_decimals(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 +715,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, decimals)
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, decimals)
# 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, decimals)
return round(mean_price * factor, 2) # "mean" return round(mean_price * factor, decimals) # "mean"
def _get_data_timestamp(self) -> datetime | None: def _get_data_timestamp(self) -> datetime | None:
""" """
@ -1089,39 +1087,15 @@ class TibberPricesSensor(TibberPricesEntity, RestoreSensor):
@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.
For MONETARY sensors: Monetary sensors follow the global user-configured rounding precision.
- Current/Next Interval Price: Show exact price with higher precision Non-monetary sensors keep their static precision from entity description.
- 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.
""" """
# Only apply dynamic precision to MONETARY sensors
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 return get_price_round_decimals(self.coordinator.config_entry)
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
@property @property
def extra_state_attributes(self) -> dict[str, Any] | None: 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 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.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_levels,
@ -51,7 +54,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 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: def aggregate_level_data(window_data: list[dict]) -> str | None:

View file

@ -1101,6 +1101,9 @@
"best_price_gap_count_override": { "best_price_gap_count_override": {
"name": "Bestpreis: Lückentoleranz" "name": "Bestpreis: Lückentoleranz"
}, },
"price_round_decimals": {
"name": "Preis: Dezimalstellen"
},
"peak_price_flex_override": { "peak_price_flex_override": {
"name": "Spitzenpreis: Flexibilität" "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." "message": "array_fields kann nur mit output_format: array_of_arrays verwendet werden. Ändere das Ausgabeformat oder entferne array_fields."
}, },
"invalid_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": { "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." "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": { "best_price_gap_count_override": {
"name": "Best Price: Gap Tolerance" "name": "Best Price: Gap Tolerance"
}, },
"price_round_decimals": {
"name": "Price: Decimal Places"
},
"peak_price_flex_override": { "peak_price_flex_override": {
"name": "Peak Price: Flexibility" "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." "message": "array_fields can only be used with output_format: array_of_arrays. Change the output format or remove array_fields."
}, },
"invalid_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": { "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." "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": { "best_price_gap_count_override": {
"name": "Beste pris: Gaptoleranse" "name": "Beste pris: Gaptoleranse"
}, },
"price_round_decimals": {
"name": "Pris: Desimaler"
},
"peak_price_flex_override": { "peak_price_flex_override": {
"name": "Topppris: Fleksibilitet" "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." "message": "array_fields kan kun brukes med output_format: array_of_arrays. Endre utdataformatet eller fjern array_fields."
}, },
"invalid_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": { "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." "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": { "best_price_gap_count_override": {
"name": "Beste prijs: Gap tolerantie" "name": "Beste prijs: Gap tolerantie"
}, },
"price_round_decimals": {
"name": "Prijs: Decimalen"
},
"peak_price_flex_override": { "peak_price_flex_override": {
"name": "Piekprijs: Flexibiliteit" "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." "message": "array_fields kan alleen gebruikt worden met output_format: array_of_arrays. Wijzig het uitvoerformaat of verwijder array_fields."
}, },
"invalid_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": { "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." "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": { "best_price_gap_count_override": {
"name": "Bästa pris: Glaptolerans" "name": "Bästa pris: Glaptolerans"
}, },
"price_round_decimals": {
"name": "Pris: Decimaler"
},
"peak_price_flex_override": { "peak_price_flex_override": {
"name": "Topppris: Flexibilitet" "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." "message": "array_fields kan bara användas med output_format: array_of_arrays. Ändra utdataformatet eller ta bort array_fields."
}, },
"invalid_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": { "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." "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."