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:
Julian Pawlowski 2025-12-22 20:25:30 +00:00
parent f57997b119
commit 11d4cbfd09
16 changed files with 356 additions and 23 deletions

View file

@ -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

View file

@ -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",

View file

@ -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",

View file

@ -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] = {}

View file

@ -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(

View file

@ -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,

View file

@ -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:

View file

@ -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,
) )

View file

@ -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,

View file

@ -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"),

View file

@ -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": {

View file

@ -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---",

View file

@ -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": {

View file

@ -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"
} }

View file

@ -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": {

View file

@ -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