mirror of
https://github.com/jpawlowski/hass.tibber_prices.git
synced 2026-03-29 21:03:40 +00:00
feat(config_flow): add price level gap tolerance for Tibber API level field
Implement gap tolerance smoothing for Tibber's price level classification (VERY_CHEAP/CHEAP/NORMAL/EXPENSIVE/VERY_EXPENSIVE), separate from the existing rating_level gap tolerance (LOW/NORMAL/HIGH). New feature: - Add CONF_PRICE_LEVEL_GAP_TOLERANCE config option with separate UI step - Implement _apply_level_gap_tolerance() using same bidirectional gravitational pull algorithm as rating gap tolerance - Add _build_level_blocks() and _merge_small_level_blocks() helper functions Config flow changes: - Add new "price_level" options step with dedicated schema - Add menu entry "🏷️ Preisniveau" / "🏷️ Price Level" - Include translations for all 5 languages (de, en, nb, nl, sv) Bug fixes: - Use copy.deepcopy() for price intervals before enrichment to prevent in-place modification of cached raw API data, which caused gap tolerance changes to not take effect when reverting settings - Clear transformation cache in invalidate_config_cache() to ensure re-enrichment with new settings Logging improvements: - Reduce options update handler from 4 INFO messages to 1 DEBUG message - Move level_filtering and period_overlap debug logs to .details logger for granular control via configuration.yaml Technical details: - level_gap_tolerance is tracked separately in transformation config hash - Algorithm: Identifies small blocks (≤ tolerance) and merges them into the larger neighboring block using gravitational pull calculation - Default: 1 (smooth single isolated intervals), Range: 0-4 Impact: Users can now stabilize Tibber's price level classification independently from the internal rating_level calculation. Prevents automation flickering caused by brief price level changes in Tibber's API.
This commit is contained in:
parent
f57997b119
commit
11d4cbfd09
16 changed files with 356 additions and 23 deletions
|
|
@ -49,6 +49,8 @@ logger:
|
||||||
custom_components.tibber_prices.coordinator.period_handlers.period_overlap.details: info
|
custom_components.tibber_prices.coordinator.period_handlers.period_overlap.details: info
|
||||||
# Outlier flex capping
|
# Outlier flex capping
|
||||||
custom_components.tibber_prices.coordinator.period_handlers.core.details: info
|
custom_components.tibber_prices.coordinator.period_handlers.core.details: info
|
||||||
|
# Level filtering details (min_distance scaling)
|
||||||
|
custom_components.tibber_prices.coordinator.period_handlers.level_filtering.details: info
|
||||||
|
|
||||||
# Interval pool details (cache operations, GC):
|
# Interval pool details (cache operations, GC):
|
||||||
# Cache lookup/miss, gap detection, fetch group additions
|
# Cache lookup/miss, gap detection, fetch group additions
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ from .config_flow_handlers.schemas import (
|
||||||
get_best_price_schema,
|
get_best_price_schema,
|
||||||
get_options_init_schema,
|
get_options_init_schema,
|
||||||
get_peak_price_schema,
|
get_peak_price_schema,
|
||||||
|
get_price_level_schema,
|
||||||
get_price_rating_schema,
|
get_price_rating_schema,
|
||||||
get_price_trend_schema,
|
get_price_trend_schema,
|
||||||
get_reauth_confirm_schema,
|
get_reauth_confirm_schema,
|
||||||
|
|
@ -41,6 +42,7 @@ __all__ = [
|
||||||
"get_best_price_schema",
|
"get_best_price_schema",
|
||||||
"get_options_init_schema",
|
"get_options_init_schema",
|
||||||
"get_peak_price_schema",
|
"get_peak_price_schema",
|
||||||
|
"get_price_level_schema",
|
||||||
"get_price_rating_schema",
|
"get_price_rating_schema",
|
||||||
"get_price_trend_schema",
|
"get_price_trend_schema",
|
||||||
"get_reauth_confirm_schema",
|
"get_reauth_confirm_schema",
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ from custom_components.tibber_prices.config_flow_handlers.schemas import (
|
||||||
get_best_price_schema,
|
get_best_price_schema,
|
||||||
get_options_init_schema,
|
get_options_init_schema,
|
||||||
get_peak_price_schema,
|
get_peak_price_schema,
|
||||||
|
get_price_level_schema,
|
||||||
get_price_rating_schema,
|
get_price_rating_schema,
|
||||||
get_price_trend_schema,
|
get_price_trend_schema,
|
||||||
get_reauth_confirm_schema,
|
get_reauth_confirm_schema,
|
||||||
|
|
@ -56,6 +57,7 @@ __all__ = [
|
||||||
"get_best_price_schema",
|
"get_best_price_schema",
|
||||||
"get_options_init_schema",
|
"get_options_init_schema",
|
||||||
"get_peak_price_schema",
|
"get_peak_price_schema",
|
||||||
|
"get_price_level_schema",
|
||||||
"get_price_rating_schema",
|
"get_price_rating_schema",
|
||||||
"get_price_trend_schema",
|
"get_price_trend_schema",
|
||||||
"get_reauth_confirm_schema",
|
"get_reauth_confirm_schema",
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ from custom_components.tibber_prices.config_flow_handlers.schemas import (
|
||||||
get_display_settings_schema,
|
get_display_settings_schema,
|
||||||
get_options_init_schema,
|
get_options_init_schema,
|
||||||
get_peak_price_schema,
|
get_peak_price_schema,
|
||||||
|
get_price_level_schema,
|
||||||
get_price_rating_schema,
|
get_price_rating_schema,
|
||||||
get_price_trend_schema,
|
get_price_trend_schema,
|
||||||
get_reset_to_defaults_schema,
|
get_reset_to_defaults_schema,
|
||||||
|
|
@ -191,6 +192,7 @@ class TibberPricesOptionsFlowHandler(OptionsFlow):
|
||||||
"general_settings",
|
"general_settings",
|
||||||
"display_settings",
|
"display_settings",
|
||||||
"current_interval_price_rating",
|
"current_interval_price_rating",
|
||||||
|
"price_level",
|
||||||
"volatility",
|
"volatility",
|
||||||
"best_price",
|
"best_price",
|
||||||
"peak_price",
|
"peak_price",
|
||||||
|
|
@ -329,6 +331,25 @@ class TibberPricesOptionsFlowHandler(OptionsFlow):
|
||||||
errors=errors,
|
errors=errors,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def async_step_price_level(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
|
||||||
|
"""Configure Tibber price level gap tolerance (smoothing for API 'level' field)."""
|
||||||
|
errors: dict[str, str] = {}
|
||||||
|
|
||||||
|
if user_input is not None:
|
||||||
|
# No validation needed - slider constraints ensure valid range
|
||||||
|
# Store flat data directly in options
|
||||||
|
self._options.update(user_input)
|
||||||
|
# async_create_entry automatically handles change detection and listener triggering
|
||||||
|
self._save_options_if_changed()
|
||||||
|
# Return to menu for more changes
|
||||||
|
return await self.async_step_init()
|
||||||
|
|
||||||
|
return self.async_show_form(
|
||||||
|
step_id="price_level",
|
||||||
|
data_schema=get_price_level_schema(self.config_entry.options),
|
||||||
|
errors=errors,
|
||||||
|
)
|
||||||
|
|
||||||
async def async_step_best_price(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
|
async def async_step_best_price(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
|
||||||
"""Configure best price period settings."""
|
"""Configure best price period settings."""
|
||||||
errors: dict[str, str] = {}
|
errors: dict[str, str] = {}
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@ from custom_components.tibber_prices.const import (
|
||||||
CONF_PEAK_PRICE_MIN_DISTANCE_FROM_AVG,
|
CONF_PEAK_PRICE_MIN_DISTANCE_FROM_AVG,
|
||||||
CONF_PEAK_PRICE_MIN_LEVEL,
|
CONF_PEAK_PRICE_MIN_LEVEL,
|
||||||
CONF_PEAK_PRICE_MIN_PERIOD_LENGTH,
|
CONF_PEAK_PRICE_MIN_PERIOD_LENGTH,
|
||||||
|
CONF_PRICE_LEVEL_GAP_TOLERANCE,
|
||||||
CONF_PRICE_RATING_GAP_TOLERANCE,
|
CONF_PRICE_RATING_GAP_TOLERANCE,
|
||||||
CONF_PRICE_RATING_HYSTERESIS,
|
CONF_PRICE_RATING_HYSTERESIS,
|
||||||
CONF_PRICE_RATING_THRESHOLD_HIGH,
|
CONF_PRICE_RATING_THRESHOLD_HIGH,
|
||||||
|
|
@ -58,6 +59,7 @@ from custom_components.tibber_prices.const import (
|
||||||
DEFAULT_PEAK_PRICE_MIN_DISTANCE_FROM_AVG,
|
DEFAULT_PEAK_PRICE_MIN_DISTANCE_FROM_AVG,
|
||||||
DEFAULT_PEAK_PRICE_MIN_LEVEL,
|
DEFAULT_PEAK_PRICE_MIN_LEVEL,
|
||||||
DEFAULT_PEAK_PRICE_MIN_PERIOD_LENGTH,
|
DEFAULT_PEAK_PRICE_MIN_PERIOD_LENGTH,
|
||||||
|
DEFAULT_PRICE_LEVEL_GAP_TOLERANCE,
|
||||||
DEFAULT_PRICE_RATING_GAP_TOLERANCE,
|
DEFAULT_PRICE_RATING_GAP_TOLERANCE,
|
||||||
DEFAULT_PRICE_RATING_HYSTERESIS,
|
DEFAULT_PRICE_RATING_HYSTERESIS,
|
||||||
DEFAULT_PRICE_RATING_THRESHOLD_HIGH,
|
DEFAULT_PRICE_RATING_THRESHOLD_HIGH,
|
||||||
|
|
@ -77,6 +79,7 @@ from custom_components.tibber_prices.const import (
|
||||||
MAX_GAP_COUNT,
|
MAX_GAP_COUNT,
|
||||||
MAX_MIN_PERIOD_LENGTH,
|
MAX_MIN_PERIOD_LENGTH,
|
||||||
MAX_MIN_PERIODS,
|
MAX_MIN_PERIODS,
|
||||||
|
MAX_PRICE_LEVEL_GAP_TOLERANCE,
|
||||||
MAX_PRICE_RATING_GAP_TOLERANCE,
|
MAX_PRICE_RATING_GAP_TOLERANCE,
|
||||||
MAX_PRICE_RATING_HYSTERESIS,
|
MAX_PRICE_RATING_HYSTERESIS,
|
||||||
MAX_PRICE_RATING_THRESHOLD_HIGH,
|
MAX_PRICE_RATING_THRESHOLD_HIGH,
|
||||||
|
|
@ -89,6 +92,7 @@ from custom_components.tibber_prices.const import (
|
||||||
MAX_VOLATILITY_THRESHOLD_VERY_HIGH,
|
MAX_VOLATILITY_THRESHOLD_VERY_HIGH,
|
||||||
MIN_GAP_COUNT,
|
MIN_GAP_COUNT,
|
||||||
MIN_PERIOD_LENGTH,
|
MIN_PERIOD_LENGTH,
|
||||||
|
MIN_PRICE_LEVEL_GAP_TOLERANCE,
|
||||||
MIN_PRICE_RATING_GAP_TOLERANCE,
|
MIN_PRICE_RATING_GAP_TOLERANCE,
|
||||||
MIN_PRICE_RATING_HYSTERESIS,
|
MIN_PRICE_RATING_HYSTERESIS,
|
||||||
MIN_PRICE_RATING_THRESHOLD_HIGH,
|
MIN_PRICE_RATING_THRESHOLD_HIGH,
|
||||||
|
|
@ -339,6 +343,30 @@ def get_price_rating_schema(options: Mapping[str, Any]) -> vol.Schema:
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_price_level_schema(options: Mapping[str, Any]) -> vol.Schema:
|
||||||
|
"""Return schema for Tibber price level stabilization (gap tolerance for API level field)."""
|
||||||
|
return vol.Schema(
|
||||||
|
{
|
||||||
|
vol.Optional(
|
||||||
|
CONF_PRICE_LEVEL_GAP_TOLERANCE,
|
||||||
|
default=int(
|
||||||
|
options.get(
|
||||||
|
CONF_PRICE_LEVEL_GAP_TOLERANCE,
|
||||||
|
DEFAULT_PRICE_LEVEL_GAP_TOLERANCE,
|
||||||
|
)
|
||||||
|
),
|
||||||
|
): NumberSelector(
|
||||||
|
NumberSelectorConfig(
|
||||||
|
min=MIN_PRICE_LEVEL_GAP_TOLERANCE,
|
||||||
|
max=MAX_PRICE_LEVEL_GAP_TOLERANCE,
|
||||||
|
step=1,
|
||||||
|
mode=NumberSelectorMode.SLIDER,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def get_volatility_schema(options: Mapping[str, Any]) -> vol.Schema:
|
def get_volatility_schema(options: Mapping[str, Any]) -> vol.Schema:
|
||||||
"""Return schema for volatility thresholds configuration."""
|
"""Return schema for volatility thresholds configuration."""
|
||||||
return vol.Schema(
|
return vol.Schema(
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,7 @@ CONF_PRICE_RATING_THRESHOLD_LOW = "price_rating_threshold_low"
|
||||||
CONF_PRICE_RATING_THRESHOLD_HIGH = "price_rating_threshold_high"
|
CONF_PRICE_RATING_THRESHOLD_HIGH = "price_rating_threshold_high"
|
||||||
CONF_PRICE_RATING_HYSTERESIS = "price_rating_hysteresis"
|
CONF_PRICE_RATING_HYSTERESIS = "price_rating_hysteresis"
|
||||||
CONF_PRICE_RATING_GAP_TOLERANCE = "price_rating_gap_tolerance"
|
CONF_PRICE_RATING_GAP_TOLERANCE = "price_rating_gap_tolerance"
|
||||||
|
CONF_PRICE_LEVEL_GAP_TOLERANCE = "price_level_gap_tolerance"
|
||||||
CONF_AVERAGE_SENSOR_DISPLAY = "average_sensor_display" # "median" or "mean"
|
CONF_AVERAGE_SENSOR_DISPLAY = "average_sensor_display" # "median" or "mean"
|
||||||
CONF_PRICE_TREND_THRESHOLD_RISING = "price_trend_threshold_rising"
|
CONF_PRICE_TREND_THRESHOLD_RISING = "price_trend_threshold_rising"
|
||||||
CONF_PRICE_TREND_THRESHOLD_FALLING = "price_trend_threshold_falling"
|
CONF_PRICE_TREND_THRESHOLD_FALLING = "price_trend_threshold_falling"
|
||||||
|
|
@ -96,6 +97,7 @@ DEFAULT_PRICE_RATING_THRESHOLD_LOW = -10 # Default rating threshold low percent
|
||||||
DEFAULT_PRICE_RATING_THRESHOLD_HIGH = 10 # Default rating threshold high percentage
|
DEFAULT_PRICE_RATING_THRESHOLD_HIGH = 10 # Default rating threshold high percentage
|
||||||
DEFAULT_PRICE_RATING_HYSTERESIS = 2.0 # Hysteresis percentage to prevent flickering at threshold boundaries
|
DEFAULT_PRICE_RATING_HYSTERESIS = 2.0 # Hysteresis percentage to prevent flickering at threshold boundaries
|
||||||
DEFAULT_PRICE_RATING_GAP_TOLERANCE = 1 # Max consecutive intervals to smooth out (0 = disabled)
|
DEFAULT_PRICE_RATING_GAP_TOLERANCE = 1 # Max consecutive intervals to smooth out (0 = disabled)
|
||||||
|
DEFAULT_PRICE_LEVEL_GAP_TOLERANCE = 1 # Max consecutive intervals to smooth out for price level (0 = disabled)
|
||||||
DEFAULT_AVERAGE_SENSOR_DISPLAY = "median" # Default: show median in state, mean in attributes
|
DEFAULT_AVERAGE_SENSOR_DISPLAY = "median" # Default: show median in state, mean in attributes
|
||||||
DEFAULT_PRICE_TREND_THRESHOLD_RISING = 3 # Default trend threshold for rising prices (%)
|
DEFAULT_PRICE_TREND_THRESHOLD_RISING = 3 # Default trend threshold for rising prices (%)
|
||||||
DEFAULT_PRICE_TREND_THRESHOLD_FALLING = -3 # Default trend threshold for falling prices (%, negative value)
|
DEFAULT_PRICE_TREND_THRESHOLD_FALLING = -3 # Default trend threshold for falling prices (%, negative value)
|
||||||
|
|
@ -139,6 +141,8 @@ MIN_PRICE_RATING_HYSTERESIS = 0.0 # Minimum hysteresis (0 = disabled)
|
||||||
MAX_PRICE_RATING_HYSTERESIS = 5.0 # Maximum hysteresis (5% band)
|
MAX_PRICE_RATING_HYSTERESIS = 5.0 # Maximum hysteresis (5% band)
|
||||||
MIN_PRICE_RATING_GAP_TOLERANCE = 0 # Minimum gap tolerance (0 = disabled)
|
MIN_PRICE_RATING_GAP_TOLERANCE = 0 # Minimum gap tolerance (0 = disabled)
|
||||||
MAX_PRICE_RATING_GAP_TOLERANCE = 4 # Maximum gap tolerance (4 intervals = 1 hour)
|
MAX_PRICE_RATING_GAP_TOLERANCE = 4 # Maximum gap tolerance (4 intervals = 1 hour)
|
||||||
|
MIN_PRICE_LEVEL_GAP_TOLERANCE = 0 # Minimum gap tolerance for price level (0 = disabled)
|
||||||
|
MAX_PRICE_LEVEL_GAP_TOLERANCE = 4 # Maximum gap tolerance for price level (4 intervals = 1 hour)
|
||||||
|
|
||||||
# Volatility threshold limits
|
# Volatility threshold limits
|
||||||
# MODERATE threshold: practical range 5% to 25% (entry point for noticeable fluctuation)
|
# MODERATE threshold: practical range 5% to 25% (entry point for noticeable fluctuation)
|
||||||
|
|
@ -339,6 +343,7 @@ def get_default_options(currency_code: str | None) -> dict[str, Any]:
|
||||||
CONF_PRICE_RATING_THRESHOLD_HIGH: DEFAULT_PRICE_RATING_THRESHOLD_HIGH,
|
CONF_PRICE_RATING_THRESHOLD_HIGH: DEFAULT_PRICE_RATING_THRESHOLD_HIGH,
|
||||||
CONF_PRICE_RATING_HYSTERESIS: DEFAULT_PRICE_RATING_HYSTERESIS,
|
CONF_PRICE_RATING_HYSTERESIS: DEFAULT_PRICE_RATING_HYSTERESIS,
|
||||||
CONF_PRICE_RATING_GAP_TOLERANCE: DEFAULT_PRICE_RATING_GAP_TOLERANCE,
|
CONF_PRICE_RATING_GAP_TOLERANCE: DEFAULT_PRICE_RATING_GAP_TOLERANCE,
|
||||||
|
CONF_PRICE_LEVEL_GAP_TOLERANCE: DEFAULT_PRICE_LEVEL_GAP_TOLERANCE,
|
||||||
# Volatility thresholds (flat - single-section step)
|
# Volatility thresholds (flat - single-section step)
|
||||||
CONF_VOLATILITY_THRESHOLD_MODERATE: DEFAULT_VOLATILITY_THRESHOLD_MODERATE,
|
CONF_VOLATILITY_THRESHOLD_MODERATE: DEFAULT_VOLATILITY_THRESHOLD_MODERATE,
|
||||||
CONF_VOLATILITY_THRESHOLD_HIGH: DEFAULT_VOLATILITY_THRESHOLD_HIGH,
|
CONF_VOLATILITY_THRESHOLD_HIGH: DEFAULT_VOLATILITY_THRESHOLD_HIGH,
|
||||||
|
|
|
||||||
|
|
@ -264,7 +264,7 @@ class TibberPricesDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||||
|
|
||||||
async def _handle_options_update(self, _hass: HomeAssistant, _config_entry: ConfigEntry) -> None:
|
async def _handle_options_update(self, _hass: HomeAssistant, _config_entry: ConfigEntry) -> None:
|
||||||
"""Handle options update by invalidating config caches and re-transforming data."""
|
"""Handle options update by invalidating config caches and re-transforming data."""
|
||||||
self._log("debug", "Options updated, invalidating config caches")
|
self._log("debug", "Options update triggered, re-transforming data")
|
||||||
self._data_transformer.invalidate_config_cache()
|
self._data_transformer.invalidate_config_cache()
|
||||||
self._period_calculator.invalidate_config_cache()
|
self._period_calculator.invalidate_config_cache()
|
||||||
|
|
||||||
|
|
@ -272,12 +272,10 @@ class TibberPricesDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||||
# This updates rating_levels, volatility, and period calculations
|
# This updates rating_levels, volatility, and period calculations
|
||||||
# without needing to fetch new data from the API
|
# without needing to fetch new data from the API
|
||||||
if self._cached_price_data:
|
if self._cached_price_data:
|
||||||
self._log("debug", "Re-transforming cached data with new configuration")
|
|
||||||
self.data = self._transform_data(self._cached_price_data)
|
self.data = self._transform_data(self._cached_price_data)
|
||||||
# Notify all listeners about the updated data
|
|
||||||
self.async_update_listeners()
|
self.async_update_listeners()
|
||||||
else:
|
else:
|
||||||
self._log("warning", "No cached data available to re-transform")
|
self._log("debug", "No cached data to re-transform")
|
||||||
|
|
||||||
@callback
|
@callback
|
||||||
def async_add_time_sensitive_listener(self, update_callback: TimeServiceCallback) -> CALLBACK_TYPE:
|
def async_add_time_sensitive_listener(self, update_callback: TimeServiceCallback) -> CALLBACK_TYPE:
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import copy
|
||||||
import logging
|
import logging
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
|
|
@ -49,7 +50,12 @@ class TibberPricesDataTransformer:
|
||||||
getattr(_LOGGER, level)(prefixed_message, *args, **kwargs)
|
getattr(_LOGGER, level)(prefixed_message, *args, **kwargs)
|
||||||
|
|
||||||
def get_threshold_percentages(self) -> dict[str, int | float]:
|
def get_threshold_percentages(self) -> dict[str, int | float]:
|
||||||
"""Get threshold percentages, hysteresis and gap tolerance from config options."""
|
"""
|
||||||
|
Get threshold percentages, hysteresis and gap tolerance for RATING_LEVEL from config options.
|
||||||
|
|
||||||
|
CRITICAL: This function is ONLY for rating_level (internal calculation: LOW/NORMAL/HIGH).
|
||||||
|
Do NOT use for price level (Tibber API: VERY_CHEAP/CHEAP/NORMAL/EXPENSIVE/VERY_EXPENSIVE).
|
||||||
|
"""
|
||||||
options = self.config_entry.options or {}
|
options = self.config_entry.options or {}
|
||||||
return {
|
return {
|
||||||
"low": options.get(_const.CONF_PRICE_RATING_THRESHOLD_LOW, _const.DEFAULT_PRICE_RATING_THRESHOLD_LOW),
|
"low": options.get(_const.CONF_PRICE_RATING_THRESHOLD_LOW, _const.DEFAULT_PRICE_RATING_THRESHOLD_LOW),
|
||||||
|
|
@ -60,11 +66,33 @@ class TibberPricesDataTransformer:
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def get_level_gap_tolerance(self) -> int:
|
||||||
|
"""
|
||||||
|
Get gap tolerance for PRICE LEVEL (Tibber API) from config options.
|
||||||
|
|
||||||
|
CRITICAL: This is separate from rating_level gap tolerance.
|
||||||
|
Price level comes from Tibber API (VERY_CHEAP/CHEAP/NORMAL/EXPENSIVE/VERY_EXPENSIVE).
|
||||||
|
Rating level is calculated internally (LOW/NORMAL/HIGH).
|
||||||
|
"""
|
||||||
|
options = self.config_entry.options or {}
|
||||||
|
return options.get(_const.CONF_PRICE_LEVEL_GAP_TOLERANCE, _const.DEFAULT_PRICE_LEVEL_GAP_TOLERANCE)
|
||||||
|
|
||||||
def invalidate_config_cache(self) -> None:
|
def invalidate_config_cache(self) -> None:
|
||||||
"""Invalidate config cache when options change."""
|
"""
|
||||||
|
Invalidate config cache AND transformation cache when options change.
|
||||||
|
|
||||||
|
CRITICAL: When options like gap_tolerance, hysteresis, or price_level_gap_tolerance
|
||||||
|
change, we must clear BOTH caches:
|
||||||
|
1. Config cache (_config_cache) - forces config rebuild on next check
|
||||||
|
2. Transformation cache (_cached_transformed_data) - forces data re-enrichment
|
||||||
|
|
||||||
|
This ensures that the next call to transform_data() will re-calculate
|
||||||
|
rating_levels and apply new gap tolerance settings to existing price data.
|
||||||
|
"""
|
||||||
self._config_cache_valid = False
|
self._config_cache_valid = False
|
||||||
self._config_cache = None
|
self._config_cache = None
|
||||||
self._log("debug", "Config cache invalidated")
|
self._cached_transformed_data = None # Force re-transformation with new config
|
||||||
|
self._last_transformation_config = None # Force config comparison to trigger
|
||||||
|
|
||||||
def _get_current_transformation_config(self) -> dict[str, Any]:
|
def _get_current_transformation_config(self) -> dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
|
|
@ -89,6 +117,7 @@ class TibberPricesDataTransformer:
|
||||||
|
|
||||||
config = {
|
config = {
|
||||||
"thresholds": self.get_threshold_percentages(),
|
"thresholds": self.get_threshold_percentages(),
|
||||||
|
"level_gap_tolerance": self.get_level_gap_tolerance(), # Separate: Tibber's price level smoothing
|
||||||
# Volatility thresholds now flat (single-section step)
|
# Volatility thresholds now flat (single-section step)
|
||||||
"volatility_thresholds": {
|
"volatility_thresholds": {
|
||||||
"moderate": options.get(_const.CONF_VOLATILITY_THRESHOLD_MODERATE, 15.0),
|
"moderate": options.get(_const.CONF_VOLATILITY_THRESHOLD_MODERATE, 15.0),
|
||||||
|
|
@ -155,8 +184,9 @@ class TibberPricesDataTransformer:
|
||||||
|
|
||||||
# Configuration changed - must retransform
|
# Configuration changed - must retransform
|
||||||
current_config = self._get_current_transformation_config()
|
current_config = self._get_current_transformation_config()
|
||||||
if current_config != self._last_transformation_config:
|
config_changed = current_config != self._last_transformation_config
|
||||||
self._log("debug", "Configuration changed, retransforming data")
|
|
||||||
|
if config_changed:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
# Check for midnight turnover
|
# Check for midnight turnover
|
||||||
|
|
@ -181,10 +211,17 @@ class TibberPricesDataTransformer:
|
||||||
source_data_timestamp = raw_data.get("timestamp")
|
source_data_timestamp = raw_data.get("timestamp")
|
||||||
|
|
||||||
# Return cached transformed data if no retransformation needed
|
# Return cached transformed data if no retransformation needed
|
||||||
if (
|
should_retransform = self._should_retransform_data(current_time, source_data_timestamp)
|
||||||
not self._should_retransform_data(current_time, source_data_timestamp)
|
has_cache = self._cached_transformed_data is not None
|
||||||
and self._cached_transformed_data is not None
|
|
||||||
):
|
self._log(
|
||||||
|
"info",
|
||||||
|
"transform_data: should_retransform=%s, has_cache=%s",
|
||||||
|
should_retransform,
|
||||||
|
has_cache,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not should_retransform and has_cache:
|
||||||
self._log("debug", "Using cached transformed data (no transformation needed)")
|
self._log("debug", "Using cached transformed data (no transformation needed)")
|
||||||
return self._cached_transformed_data
|
return self._cached_transformed_data
|
||||||
|
|
||||||
|
|
@ -192,7 +229,10 @@ class TibberPricesDataTransformer:
|
||||||
|
|
||||||
# Extract data from single-home structure
|
# Extract data from single-home structure
|
||||||
home_id = raw_data.get("home_id", "")
|
home_id = raw_data.get("home_id", "")
|
||||||
all_intervals = raw_data.get("price_info", [])
|
# CRITICAL: Make a deep copy of intervals to avoid modifying cached raw data
|
||||||
|
# The enrichment function modifies intervals in-place, which would corrupt
|
||||||
|
# the original API data and make re-enrichment with different settings impossible
|
||||||
|
all_intervals = copy.deepcopy(raw_data.get("price_info", []))
|
||||||
currency = raw_data.get("currency", "EUR")
|
currency = raw_data.get("currency", "EUR")
|
||||||
|
|
||||||
if not all_intervals:
|
if not all_intervals:
|
||||||
|
|
@ -209,13 +249,16 @@ class TibberPricesDataTransformer:
|
||||||
|
|
||||||
# Enrich price info dynamically with calculated differences and rating levels
|
# Enrich price info dynamically with calculated differences and rating levels
|
||||||
# (Modifies all_intervals in-place, returns same list)
|
# (Modifies all_intervals in-place, returns same list)
|
||||||
thresholds = self.get_threshold_percentages()
|
thresholds = self.get_threshold_percentages() # Only for rating_level
|
||||||
|
level_gap_tolerance = self.get_level_gap_tolerance() # Separate: for Tibber's price level
|
||||||
|
|
||||||
enriched_intervals = enrich_price_info_with_differences(
|
enriched_intervals = enrich_price_info_with_differences(
|
||||||
all_intervals,
|
all_intervals,
|
||||||
threshold_low=thresholds["low"],
|
threshold_low=thresholds["low"],
|
||||||
threshold_high=thresholds["high"],
|
threshold_high=thresholds["high"],
|
||||||
hysteresis=float(thresholds["hysteresis"]),
|
hysteresis=float(thresholds["hysteresis"]),
|
||||||
gap_tolerance=int(thresholds["gap_tolerance"]),
|
gap_tolerance=int(thresholds["gap_tolerance"]),
|
||||||
|
level_gap_tolerance=level_gap_tolerance,
|
||||||
time=self.time,
|
time=self.time,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -181,7 +181,7 @@ def check_interval_criteria(
|
||||||
if scale_factor < SCALE_FACTOR_WARNING_THRESHOLD:
|
if scale_factor < SCALE_FACTOR_WARNING_THRESHOLD:
|
||||||
import logging # noqa: PLC0415
|
import logging # noqa: PLC0415
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__) # noqa: N806
|
_LOGGER = logging.getLogger(f"{__name__}.details") # noqa: N806
|
||||||
_LOGGER.debug(
|
_LOGGER.debug(
|
||||||
"High flex %.1f%% detected: Reducing min_distance %.1f%% → %.1f%% (scale %.2f)",
|
"High flex %.1f%% detected: Reducing min_distance %.1f%% → %.1f%% (scale %.2f)",
|
||||||
flex_abs * 100,
|
flex_abs * 100,
|
||||||
|
|
|
||||||
|
|
@ -105,7 +105,7 @@ def merge_adjacent_periods(period1: dict, period2: dict) -> dict:
|
||||||
"period2_end": period2["end"].isoformat(),
|
"period2_end": period2["end"].isoformat(),
|
||||||
}
|
}
|
||||||
|
|
||||||
_LOGGER.debug(
|
_LOGGER_DETAILS.debug(
|
||||||
"%sMerged periods: %s-%s + %s-%s → %s-%s (duration: %d min)",
|
"%sMerged periods: %s-%s + %s-%s → %s-%s (duration: %d min)",
|
||||||
INDENT_L2,
|
INDENT_L2,
|
||||||
period1["start"].strftime("%H:%M"),
|
period1["start"].strftime("%H:%M"),
|
||||||
|
|
@ -145,7 +145,7 @@ def resolve_period_overlaps(
|
||||||
- new_periods_count: Number of new periods added (some may have been merged)
|
- new_periods_count: Number of new periods added (some may have been merged)
|
||||||
|
|
||||||
"""
|
"""
|
||||||
_LOGGER.debug(
|
_LOGGER_DETAILS.debug(
|
||||||
"%sresolve_period_overlaps called: existing=%d, new=%d",
|
"%sresolve_period_overlaps called: existing=%d, new=%d",
|
||||||
INDENT_L0,
|
INDENT_L0,
|
||||||
len(existing_periods),
|
len(existing_periods),
|
||||||
|
|
@ -175,7 +175,7 @@ def resolve_period_overlaps(
|
||||||
and abs((relaxed_end - existing["end"]).total_seconds()) < tolerance_seconds
|
and abs((relaxed_end - existing["end"]).total_seconds()) < tolerance_seconds
|
||||||
):
|
):
|
||||||
is_duplicate = True
|
is_duplicate = True
|
||||||
_LOGGER.debug(
|
_LOGGER_DETAILS.debug(
|
||||||
"%sSkipping duplicate period %s-%s (already exists)",
|
"%sSkipping duplicate period %s-%s (already exists)",
|
||||||
INDENT_L1,
|
INDENT_L1,
|
||||||
relaxed_start.strftime("%H:%M"),
|
relaxed_start.strftime("%H:%M"),
|
||||||
|
|
@ -198,7 +198,7 @@ def resolve_period_overlaps(
|
||||||
|
|
||||||
if is_adjacent or is_overlapping:
|
if is_adjacent or is_overlapping:
|
||||||
periods_to_merge.append((idx, existing))
|
periods_to_merge.append((idx, existing))
|
||||||
_LOGGER.debug(
|
_LOGGER_DETAILS.debug(
|
||||||
"%sPeriod %s-%s %s with existing period %s-%s",
|
"%sPeriod %s-%s %s with existing period %s-%s",
|
||||||
INDENT_L1,
|
INDENT_L1,
|
||||||
relaxed_start.strftime("%H:%M"),
|
relaxed_start.strftime("%H:%M"),
|
||||||
|
|
@ -212,7 +212,7 @@ def resolve_period_overlaps(
|
||||||
# No merge needed - add as new period
|
# No merge needed - add as new period
|
||||||
merged.append(relaxed)
|
merged.append(relaxed)
|
||||||
periods_added += 1
|
periods_added += 1
|
||||||
_LOGGER.debug(
|
_LOGGER_DETAILS.debug(
|
||||||
"%sAdded new period %s-%s (no overlap/adjacency)",
|
"%sAdded new period %s-%s (no overlap/adjacency)",
|
||||||
INDENT_L1,
|
INDENT_L1,
|
||||||
relaxed_start.strftime("%H:%M"),
|
relaxed_start.strftime("%H:%M"),
|
||||||
|
|
|
||||||
|
|
@ -136,6 +136,7 @@
|
||||||
"general_settings": "⚙️ Allgemeine Einstellungen",
|
"general_settings": "⚙️ Allgemeine Einstellungen",
|
||||||
"display_settings": "💱 Währungsanzeige",
|
"display_settings": "💱 Währungsanzeige",
|
||||||
"current_interval_price_rating": "📊 Preisbewertung",
|
"current_interval_price_rating": "📊 Preisbewertung",
|
||||||
|
"price_level": "🏷️ Preisniveau",
|
||||||
"volatility": "💨 Preis-Volatilität",
|
"volatility": "💨 Preis-Volatilität",
|
||||||
"best_price": "💚 Bestpreis",
|
"best_price": "💚 Bestpreis",
|
||||||
"peak_price": "🔴 Spitzenpreis",
|
"peak_price": "🔴 Spitzenpreis",
|
||||||
|
|
@ -320,6 +321,17 @@
|
||||||
"confirm_reset": "Ja, alles auf Werkseinstellungen zurücksetzen"
|
"confirm_reset": "Ja, alles auf Werkseinstellungen zurücksetzen"
|
||||||
},
|
},
|
||||||
"submit": "Jetzt zurücksetzen"
|
"submit": "Jetzt zurücksetzen"
|
||||||
|
},
|
||||||
|
"price_level": {
|
||||||
|
"title": "🏷️ Preisniveau-Einstellungen (von Tibber API)",
|
||||||
|
"description": "**Konfiguriere die Stabilisierung für Tibbers Preisniveau-Klassifizierung (sehr günstig/günstig/normal/teuer/sehr teuer).**\n\nTibbers API liefert ein Preisniveau-Feld für jedes Intervall. Diese Einstellung glättet kurze Schwankungen, um Instabilität in Automatisierungen zu verhindern.",
|
||||||
|
"data": {
|
||||||
|
"price_level_gap_tolerance": "Gap-Toleranz"
|
||||||
|
},
|
||||||
|
"data_description": {
|
||||||
|
"price_level_gap_tolerance": "Maximale Anzahl aufeinanderfolgender Intervalle, die 'geglättet' werden können, wenn sie von umgebenden Preisniveaus abweichen. Kleine isolierte Niveauänderungen werden mit dem dominanten Nachbarblock zusammengeführt. Beispiel: 1 bedeutet, dass ein einzelnes 'normal'-Intervall, umgeben von 'günstig'-Intervallen, zu 'günstig' korrigiert wird. Auf 0 setzen zum Deaktivieren. Standard: 1"
|
||||||
|
},
|
||||||
|
"submit": "↩ Speichern & Zurück"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
|
|
|
||||||
|
|
@ -136,6 +136,7 @@
|
||||||
"general_settings": "⚙️ General Settings",
|
"general_settings": "⚙️ General Settings",
|
||||||
"display_settings": "💱 Currency Display",
|
"display_settings": "💱 Currency Display",
|
||||||
"current_interval_price_rating": "📊 Price Rating",
|
"current_interval_price_rating": "📊 Price Rating",
|
||||||
|
"price_level": "🏷️ Price Level",
|
||||||
"volatility": "💨 Price Volatility",
|
"volatility": "💨 Price Volatility",
|
||||||
"best_price": "💚 Best Price Period",
|
"best_price": "💚 Best Price Period",
|
||||||
"peak_price": "🔴 Peak Price Period",
|
"peak_price": "🔴 Peak Price Period",
|
||||||
|
|
@ -186,6 +187,17 @@
|
||||||
},
|
},
|
||||||
"submit": "↩ Save & Back"
|
"submit": "↩ Save & Back"
|
||||||
},
|
},
|
||||||
|
"price_level": {
|
||||||
|
"title": "🏷️ Price Level Settings",
|
||||||
|
"description": "**Configure stabilization for Tibber's price level classification (very cheap/cheap/normal/expensive/very expensive).**\n\nTibber's API provides a price level field for each interval. This setting smooths out brief fluctuations to prevent automation instability.",
|
||||||
|
"data": {
|
||||||
|
"price_level_gap_tolerance": "Gap Tolerance"
|
||||||
|
},
|
||||||
|
"data_description": {
|
||||||
|
"price_level_gap_tolerance": "Maximum number of consecutive intervals that can be 'smoothed out' if they differ from surrounding price levels. Small isolated level changes are merged into the dominant neighboring block. Example: 1 means a single 'normal' interval surrounded by 'cheap' intervals gets corrected to 'cheap'. Set to 0 to disable. Default: 1"
|
||||||
|
},
|
||||||
|
"submit": "↩ Save & Back"
|
||||||
|
},
|
||||||
"best_price": {
|
"best_price": {
|
||||||
"title": "💚 Best Price Period Settings",
|
"title": "💚 Best Price Period Settings",
|
||||||
"description": "**Configure settings for the Best Price Period binary sensor. This sensor is active during periods with the lowest electricity prices.**\n\n---",
|
"description": "**Configure settings for the Best Price Period binary sensor. This sensor is active during periods with the lowest electricity prices.**\n\n---",
|
||||||
|
|
|
||||||
|
|
@ -136,6 +136,7 @@
|
||||||
"general_settings": "⚙️ Generelle innstillinger",
|
"general_settings": "⚙️ Generelle innstillinger",
|
||||||
"display_settings": "💱 Valutavisning",
|
"display_settings": "💱 Valutavisning",
|
||||||
"current_interval_price_rating": "📊 Prisvurdering",
|
"current_interval_price_rating": "📊 Prisvurdering",
|
||||||
|
"price_level": "🏷️ Prisnivå",
|
||||||
"volatility": "💨 Prisvolatilitet",
|
"volatility": "💨 Prisvolatilitet",
|
||||||
"best_price": "💚 Beste prisperiode",
|
"best_price": "💚 Beste prisperiode",
|
||||||
"peak_price": "🔴 Toppprisperiode",
|
"peak_price": "🔴 Toppprisperiode",
|
||||||
|
|
@ -320,6 +321,17 @@
|
||||||
"confirm_reset": "Ja, tilbakestill alt til standard"
|
"confirm_reset": "Ja, tilbakestill alt til standard"
|
||||||
},
|
},
|
||||||
"submit": "Tilbakestill nå"
|
"submit": "Tilbakestill nå"
|
||||||
|
},
|
||||||
|
"price_level": {
|
||||||
|
"title": "🏷️ Prisnivå-innstillinger",
|
||||||
|
"description": "**Konfigurer stabilisering for Tibbers prisnivå-klassifisering (veldig billig/billig/normal/dyr/veldig dyr).**\n\nTibbers API gir et prisnivå-felt for hvert intervall. Denne innstillingen jevner ut korte svingninger for å forhindre ustabilitet i automatiseringer.",
|
||||||
|
"data": {
|
||||||
|
"price_level_gap_tolerance": "Gap-toleranse"
|
||||||
|
},
|
||||||
|
"data_description": {
|
||||||
|
"price_level_gap_tolerance": "Maksimalt antall påfølgende intervaller som kan 'jevnes ut' hvis de avviker fra omkringliggende prisnivåer. Små isolerte nivåendringer slås sammen med den dominerende nabogruppen. Eksempel: 1 betyr at et enkelt 'normal'-intervall omgitt av 'billig'-intervaller korrigeres til 'billig'. Sett til 0 for å deaktivere. Standard: 1"
|
||||||
|
},
|
||||||
|
"submit": "↩ Lagre & tilbake"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
|
|
|
||||||
|
|
@ -136,6 +136,7 @@
|
||||||
"general_settings": "⚙️ Algemene Instellingen",
|
"general_settings": "⚙️ Algemene Instellingen",
|
||||||
"display_settings": "💱 Valuta Weergave",
|
"display_settings": "💱 Valuta Weergave",
|
||||||
"current_interval_price_rating": "📊 Prijsbeoordeling",
|
"current_interval_price_rating": "📊 Prijsbeoordeling",
|
||||||
|
"price_level": "🏷️ Prijsniveau",
|
||||||
"volatility": "💨 Prijsvolatiliteit",
|
"volatility": "💨 Prijsvolatiliteit",
|
||||||
"best_price": "💚 Beste Prijs Periode",
|
"best_price": "💚 Beste Prijs Periode",
|
||||||
"peak_price": "🔴 Piekprijs Periode",
|
"peak_price": "🔴 Piekprijs Periode",
|
||||||
|
|
@ -320,6 +321,17 @@
|
||||||
"confirm_reset": "Ja, reset alles naar standaardwaarden"
|
"confirm_reset": "Ja, reset alles naar standaardwaarden"
|
||||||
},
|
},
|
||||||
"submit": "Nu Resetten"
|
"submit": "Nu Resetten"
|
||||||
|
},
|
||||||
|
"price_level": {
|
||||||
|
"title": "🏷️ Prijsniveau-instellingen",
|
||||||
|
"description": "**Configureer stabilisatie voor Tibbers prijsniveau-classificatie (zeer goedkoop/goedkoop/normaal/duur/zeer duur).**\n\nTibbers API levert een prijsniveau-veld voor elk interval. Deze instelling egaliseer korte fluctuaties om instabiliteit in automatiseringen te voorkomen.",
|
||||||
|
"data": {
|
||||||
|
"price_level_gap_tolerance": "Gap-tolerantie"
|
||||||
|
},
|
||||||
|
"data_description": {
|
||||||
|
"price_level_gap_tolerance": "Maximaal aantal opeenvolgende intervallen dat 'afgevlakt' kan worden als ze afwijken van omringende prijsniveaus. Kleine geïsoleerde niveauwijzigingen worden samengevoegd met het dominante aangrenzende blok. Voorbeeld: 1 betekent dat een enkel 'normaal'-interval omringd door 'goedkoop'-intervallen wordt gecorrigeerd naar 'goedkoop'. Stel in op 0 om uit te schakelen. Standaard: 1"
|
||||||
|
},
|
||||||
|
"submit": "↩ Opslaan & terug"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
|
|
@ -1157,4 +1169,4 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"title": "Tibber Prijsinformatie & Beoordelingen"
|
"title": "Tibber Prijsinformatie & Beoordelingen"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -136,6 +136,7 @@
|
||||||
"general_settings": "⚙️ Allmänna inställningar",
|
"general_settings": "⚙️ Allmänna inställningar",
|
||||||
"display_settings": "💱 Valutavisning",
|
"display_settings": "💱 Valutavisning",
|
||||||
"current_interval_price_rating": "📊 Prisbetyg",
|
"current_interval_price_rating": "📊 Prisbetyg",
|
||||||
|
"price_level": "🏷️ Prisnivå",
|
||||||
"volatility": "💨 Prisvolatilitet",
|
"volatility": "💨 Prisvolatilitet",
|
||||||
"best_price": "💚 Bästa Prisperiod",
|
"best_price": "💚 Bästa Prisperiod",
|
||||||
"peak_price": "🔴 Topprisperiod",
|
"peak_price": "🔴 Topprisperiod",
|
||||||
|
|
@ -320,6 +321,17 @@
|
||||||
"confirm_reset": "Ja, återställ allt till standard"
|
"confirm_reset": "Ja, återställ allt till standard"
|
||||||
},
|
},
|
||||||
"submit": "Återställ nu"
|
"submit": "Återställ nu"
|
||||||
|
},
|
||||||
|
"price_level": {
|
||||||
|
"title": "<22><>️ Prisnivå-inställningar",
|
||||||
|
"description": "**Konfigurera stabilisering för Tibbers prisnivå-klassificering (mycket billig/billig/normal/dyr/mycket dyr).**\n\nTibbers API tillhandahåller ett prisnivå-fält för varje intervall. Denna inställning jämnar ut korta fluktuationer för att förhindra instabilitet i automatiseringar.",
|
||||||
|
"data": {
|
||||||
|
"price_level_gap_tolerance": "Gap-tolerans"
|
||||||
|
},
|
||||||
|
"data_description": {
|
||||||
|
"price_level_gap_tolerance": "Maximalt antal på varandra följande intervaller som kan 'jämnas ut' om de avviker från omgivande prisnivåer. Små isolerade nivåförändringar sammanfogas med det dominerande grannblocket. Exempel: 1 betyder att ett enstaka 'normal'-intervall omgivet av 'billig'-intervaller korrigeras till 'billig'. Sätt till 0 för att inaktivera. Standard: 1"
|
||||||
|
},
|
||||||
|
"submit": "↩ Spara & tillbaka"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ if TYPE_CHECKING:
|
||||||
from custom_components.tibber_prices.coordinator.time_service import TibberPricesTimeService
|
from custom_components.tibber_prices.coordinator.time_service import TibberPricesTimeService
|
||||||
|
|
||||||
from custom_components.tibber_prices.const import (
|
from custom_components.tibber_prices.const import (
|
||||||
|
DEFAULT_PRICE_LEVEL_GAP_TOLERANCE,
|
||||||
DEFAULT_PRICE_RATING_GAP_TOLERANCE,
|
DEFAULT_PRICE_RATING_GAP_TOLERANCE,
|
||||||
DEFAULT_PRICE_RATING_HYSTERESIS,
|
DEFAULT_PRICE_RATING_HYSTERESIS,
|
||||||
DEFAULT_VOLATILITY_THRESHOLD_HIGH,
|
DEFAULT_VOLATILITY_THRESHOLD_HIGH,
|
||||||
|
|
@ -360,6 +361,39 @@ def _build_rating_blocks(
|
||||||
return blocks
|
return blocks
|
||||||
|
|
||||||
|
|
||||||
|
def _build_level_blocks(
|
||||||
|
level_intervals: list[tuple[int, dict[str, Any], str]],
|
||||||
|
) -> list[tuple[int, int, str, int]]:
|
||||||
|
"""
|
||||||
|
Build list of contiguous price level blocks from intervals.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
level_intervals: List of (original_idx, interval_dict, level) tuples
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of (start_idx, end_idx, level, length) tuples where indices
|
||||||
|
refer to positions in level_intervals
|
||||||
|
|
||||||
|
"""
|
||||||
|
blocks: list[tuple[int, int, str, int]] = []
|
||||||
|
if not level_intervals:
|
||||||
|
return blocks
|
||||||
|
|
||||||
|
block_start = 0
|
||||||
|
current_level = level_intervals[0][2]
|
||||||
|
|
||||||
|
for idx in range(1, len(level_intervals)):
|
||||||
|
if level_intervals[idx][2] != current_level:
|
||||||
|
# End current block
|
||||||
|
blocks.append((block_start, idx - 1, current_level, idx - block_start))
|
||||||
|
block_start = idx
|
||||||
|
current_level = level_intervals[idx][2]
|
||||||
|
|
||||||
|
# Don't forget the last block
|
||||||
|
blocks.append((block_start, len(level_intervals) - 1, current_level, len(level_intervals) - block_start))
|
||||||
|
return blocks
|
||||||
|
|
||||||
|
|
||||||
def _calculate_gravitational_pull(
|
def _calculate_gravitational_pull(
|
||||||
blocks: list[tuple[int, int, str, int]],
|
blocks: list[tuple[int, int, str, int]],
|
||||||
block_idx: int,
|
block_idx: int,
|
||||||
|
|
@ -478,6 +512,75 @@ def _apply_rating_gap_tolerance(
|
||||||
_LOGGER.debug("Gap tolerance: total %d block merges across all passes", total_corrections)
|
_LOGGER.debug("Gap tolerance: total %d block merges across all passes", total_corrections)
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_level_gap_tolerance(
|
||||||
|
all_intervals: list[dict[str, Any]],
|
||||||
|
gap_tolerance: int,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Apply gap tolerance to smooth out isolated price level changes.
|
||||||
|
|
||||||
|
Similar to rating gap tolerance, but operates on Tibber's "level" field
|
||||||
|
(VERY_CHEAP, CHEAP, NORMAL, EXPENSIVE, VERY_EXPENSIVE). Identifies short
|
||||||
|
sequences of intervals (≤ gap_tolerance) and merges them into the larger
|
||||||
|
neighboring block.
|
||||||
|
|
||||||
|
Example with gap_tolerance=1:
|
||||||
|
CHEAP CHEAP CHEAP NORMAL CHEAP CHEAP → CHEAP CHEAP CHEAP CHEAP CHEAP CHEAP
|
||||||
|
(single NORMAL gets merged into larger CHEAP block)
|
||||||
|
|
||||||
|
Example with gap_tolerance=1 (bidirectional):
|
||||||
|
NORMAL NORMAL EXPENSIVE NORMAL EXPENSIVE EXPENSIVE EXPENSIVE →
|
||||||
|
NORMAL NORMAL EXPENSIVE EXPENSIVE EXPENSIVE EXPENSIVE EXPENSIVE
|
||||||
|
(single NORMAL at position 4 gets merged into larger EXPENSIVE block on the right)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
all_intervals: List of price intervals with level already set (modified in-place)
|
||||||
|
gap_tolerance: Maximum number of consecutive "different" intervals to smooth out
|
||||||
|
|
||||||
|
Note:
|
||||||
|
- Uses same bidirectional algorithm as rating gap tolerance
|
||||||
|
- Compares block sizes on both sides and merges small blocks into larger neighbors
|
||||||
|
- If both neighbors have equal size, prefers the LEFT neighbor (earlier in time)
|
||||||
|
- Skips intervals without level (None)
|
||||||
|
- Intervals must be sorted chronologically for this to work correctly
|
||||||
|
- Multiple passes may be needed as merging can create new small blocks
|
||||||
|
|
||||||
|
"""
|
||||||
|
if gap_tolerance <= 0:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Extract intervals with valid level in chronological order
|
||||||
|
level_intervals: list[tuple[int, dict[str, Any], str]] = [
|
||||||
|
(i, interval, interval["level"])
|
||||||
|
for i, interval in enumerate(all_intervals)
|
||||||
|
if interval.get("level") is not None
|
||||||
|
]
|
||||||
|
|
||||||
|
if len(level_intervals) < 3: # noqa: PLR2004 - Minimum 3 for before/gap/after pattern
|
||||||
|
return
|
||||||
|
|
||||||
|
# Iteratively merge small blocks until no more changes
|
||||||
|
max_iterations = 10
|
||||||
|
total_corrections = 0
|
||||||
|
|
||||||
|
for iteration in range(max_iterations):
|
||||||
|
blocks = _build_level_blocks(level_intervals)
|
||||||
|
corrections_this_pass = _merge_small_level_blocks(blocks, level_intervals, gap_tolerance)
|
||||||
|
total_corrections += corrections_this_pass
|
||||||
|
|
||||||
|
if corrections_this_pass == 0:
|
||||||
|
break
|
||||||
|
|
||||||
|
_LOGGER.debug(
|
||||||
|
"Level gap tolerance pass %d: merged %d small blocks",
|
||||||
|
iteration + 1,
|
||||||
|
corrections_this_pass,
|
||||||
|
)
|
||||||
|
|
||||||
|
if total_corrections > 0:
|
||||||
|
_LOGGER.debug("Level gap tolerance: total %d block merges across all passes", total_corrections)
|
||||||
|
|
||||||
|
|
||||||
def _merge_small_blocks(
|
def _merge_small_blocks(
|
||||||
blocks: list[tuple[int, int, str, int]],
|
blocks: list[tuple[int, int, str, int]],
|
||||||
rated_intervals: list[tuple[int, dict[str, Any], str]],
|
rated_intervals: list[tuple[int, dict[str, Any], str]],
|
||||||
|
|
@ -535,6 +638,63 @@ def _merge_small_blocks(
|
||||||
return len(merge_decisions)
|
return len(merge_decisions)
|
||||||
|
|
||||||
|
|
||||||
|
def _merge_small_level_blocks(
|
||||||
|
blocks: list[tuple[int, int, str, int]],
|
||||||
|
level_intervals: list[tuple[int, dict[str, Any], str]],
|
||||||
|
gap_tolerance: int,
|
||||||
|
) -> int:
|
||||||
|
"""
|
||||||
|
Merge small price level blocks into their larger neighbors.
|
||||||
|
|
||||||
|
CRITICAL: This function collects ALL merge decisions FIRST, then applies them.
|
||||||
|
This prevents the order of processing from affecting outcomes. Without this,
|
||||||
|
earlier blocks could be merged incorrectly because the gravitational pull
|
||||||
|
calculation would see already-modified neighbors instead of the original state.
|
||||||
|
|
||||||
|
The merge decision is based on the FIRST LARGE BLOCK in each direction,
|
||||||
|
looking through any small intervening blocks. This ensures consistent
|
||||||
|
behavior when multiple small blocks are adjacent.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
blocks: List of (start_idx, end_idx, level, length) tuples
|
||||||
|
level_intervals: List of (original_idx, interval_dict, level) tuples (modified in-place)
|
||||||
|
gap_tolerance: Maximum size of blocks to merge
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Number of blocks merged in this pass
|
||||||
|
|
||||||
|
"""
|
||||||
|
# Phase 1: Collect all merge decisions based on ORIGINAL block state
|
||||||
|
merge_decisions: list[tuple[int, int, str]] = [] # (start_li_idx, end_li_idx, target_level)
|
||||||
|
|
||||||
|
for block_idx, (start, end, level, length) in enumerate(blocks):
|
||||||
|
if length > gap_tolerance:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Must have neighbors on BOTH sides (not an edge block)
|
||||||
|
if block_idx == 0 or block_idx == len(blocks) - 1:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Calculate gravitational pull from each direction
|
||||||
|
left_pull, left_level = _calculate_gravitational_pull(blocks, block_idx, "left", gap_tolerance)
|
||||||
|
right_pull, right_level = _calculate_gravitational_pull(blocks, block_idx, "right", gap_tolerance)
|
||||||
|
|
||||||
|
# Determine target level (prefer left if equal)
|
||||||
|
target_level = left_level if left_pull >= right_pull else right_level
|
||||||
|
|
||||||
|
if level != target_level:
|
||||||
|
merge_decisions.append((start, end, target_level))
|
||||||
|
|
||||||
|
# Phase 2: Apply all merge decisions
|
||||||
|
for start, end, target_level in merge_decisions:
|
||||||
|
for li_idx in range(start, end + 1):
|
||||||
|
original_idx, interval, _old_level = level_intervals[li_idx]
|
||||||
|
interval["level"] = target_level
|
||||||
|
level_intervals[li_idx] = (original_idx, interval, target_level)
|
||||||
|
|
||||||
|
return len(merge_decisions)
|
||||||
|
|
||||||
|
|
||||||
def enrich_price_info_with_differences( # noqa: PLR0913 - Extra params for rating stabilization
|
def enrich_price_info_with_differences( # noqa: PLR0913 - Extra params for rating stabilization
|
||||||
all_intervals: list[dict[str, Any]],
|
all_intervals: list[dict[str, Any]],
|
||||||
*,
|
*,
|
||||||
|
|
@ -542,6 +702,7 @@ def enrich_price_info_with_differences( # noqa: PLR0913 - Extra params for rati
|
||||||
threshold_high: float | None = None,
|
threshold_high: float | None = None,
|
||||||
hysteresis: float | None = None,
|
hysteresis: float | None = None,
|
||||||
gap_tolerance: int | None = None,
|
gap_tolerance: int | None = None,
|
||||||
|
level_gap_tolerance: int | None = None,
|
||||||
time: TibberPricesTimeService | None = None, # noqa: ARG001 # Used in production (via coordinator), kept for compatibility
|
time: TibberPricesTimeService | None = None, # noqa: ARG001 # Used in production (via coordinator), kept for compatibility
|
||||||
) -> list[dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
|
|
@ -558,6 +719,10 @@ def enrich_price_info_with_differences( # noqa: PLR0913 - Extra params for rati
|
||||||
remaining isolated rating changes (e.g., a single NORMAL interval surrounded
|
remaining isolated rating changes (e.g., a single NORMAL interval surrounded
|
||||||
by LOW intervals gets corrected to LOW).
|
by LOW intervals gets corrected to LOW).
|
||||||
|
|
||||||
|
Similarly, applies level gap tolerance to smooth out isolated price level changes
|
||||||
|
from Tibber's API (e.g., a single NORMAL interval surrounded by CHEAP intervals
|
||||||
|
gets corrected to CHEAP).
|
||||||
|
|
||||||
CRITICAL: Only enriches intervals that have at least 24 hours of prior data
|
CRITICAL: Only enriches intervals that have at least 24 hours of prior data
|
||||||
available. This is determined by checking if (interval_start - earliest_interval_start) >= 24h.
|
available. This is determined by checking if (interval_start - earliest_interval_start) >= 24h.
|
||||||
Works independently of interval density (24 vs 96 intervals/day) and handles
|
Works independently of interval density (24 vs 96 intervals/day) and handles
|
||||||
|
|
@ -572,7 +737,8 @@ def enrich_price_info_with_differences( # noqa: PLR0913 - Extra params for rati
|
||||||
threshold_low: Low threshold percentage for rating_level (defaults to -10)
|
threshold_low: Low threshold percentage for rating_level (defaults to -10)
|
||||||
threshold_high: High threshold percentage for rating_level (defaults to 10)
|
threshold_high: High threshold percentage for rating_level (defaults to 10)
|
||||||
hysteresis: Hysteresis percentage to prevent flickering (defaults to 2.0)
|
hysteresis: Hysteresis percentage to prevent flickering (defaults to 2.0)
|
||||||
gap_tolerance: Max consecutive intervals to smooth out (defaults to 1, 0 = disabled)
|
gap_tolerance: Max consecutive intervals to smooth out for rating_level (defaults to 1, 0 = disabled)
|
||||||
|
level_gap_tolerance: Max consecutive intervals to smooth out for price level (defaults to 1, 0 = disabled)
|
||||||
time: TibberPricesTimeService instance (kept for API compatibility, not used)
|
time: TibberPricesTimeService instance (kept for API compatibility, not used)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
|
|
@ -590,6 +756,7 @@ def enrich_price_info_with_differences( # noqa: PLR0913 - Extra params for rati
|
||||||
threshold_high = threshold_high if threshold_high is not None else 10
|
threshold_high = threshold_high if threshold_high is not None else 10
|
||||||
hysteresis = hysteresis if hysteresis is not None else DEFAULT_PRICE_RATING_HYSTERESIS
|
hysteresis = hysteresis if hysteresis is not None else DEFAULT_PRICE_RATING_HYSTERESIS
|
||||||
gap_tolerance = gap_tolerance if gap_tolerance is not None else DEFAULT_PRICE_RATING_GAP_TOLERANCE
|
gap_tolerance = gap_tolerance if gap_tolerance is not None else DEFAULT_PRICE_RATING_GAP_TOLERANCE
|
||||||
|
level_gap_tolerance = level_gap_tolerance if level_gap_tolerance is not None else DEFAULT_PRICE_LEVEL_GAP_TOLERANCE
|
||||||
|
|
||||||
if not all_intervals:
|
if not all_intervals:
|
||||||
return all_intervals
|
return all_intervals
|
||||||
|
|
@ -645,6 +812,11 @@ def enrich_price_info_with_differences( # noqa: PLR0913 - Extra params for rati
|
||||||
if gap_tolerance > 0:
|
if gap_tolerance > 0:
|
||||||
_apply_rating_gap_tolerance(all_intervals, gap_tolerance)
|
_apply_rating_gap_tolerance(all_intervals, gap_tolerance)
|
||||||
|
|
||||||
|
# Apply level gap tolerance as post-processing step
|
||||||
|
# This smooths out isolated price level changes from Tibber's API
|
||||||
|
if level_gap_tolerance > 0:
|
||||||
|
_apply_level_gap_tolerance(all_intervals, level_gap_tolerance)
|
||||||
|
|
||||||
return all_intervals
|
return all_intervals
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue