diff --git a/config/configuration.yaml b/config/configuration.yaml index 4e0dff5..4495953 100644 --- a/config/configuration.yaml +++ b/config/configuration.yaml @@ -49,6 +49,8 @@ logger: custom_components.tibber_prices.coordinator.period_handlers.period_overlap.details: info # Outlier flex capping 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): # Cache lookup/miss, gap detection, fetch group additions diff --git a/custom_components/tibber_prices/config_flow.py b/custom_components/tibber_prices/config_flow.py index f5b4654..1ef6a66 100644 --- a/custom_components/tibber_prices/config_flow.py +++ b/custom_components/tibber_prices/config_flow.py @@ -14,6 +14,7 @@ from .config_flow_handlers.schemas import ( get_best_price_schema, get_options_init_schema, get_peak_price_schema, + get_price_level_schema, get_price_rating_schema, get_price_trend_schema, get_reauth_confirm_schema, @@ -41,6 +42,7 @@ __all__ = [ "get_best_price_schema", "get_options_init_schema", "get_peak_price_schema", + "get_price_level_schema", "get_price_rating_schema", "get_price_trend_schema", "get_reauth_confirm_schema", diff --git a/custom_components/tibber_prices/config_flow_handlers/__init__.py b/custom_components/tibber_prices/config_flow_handlers/__init__.py index 72837a3..45ef991 100644 --- a/custom_components/tibber_prices/config_flow_handlers/__init__.py +++ b/custom_components/tibber_prices/config_flow_handlers/__init__.py @@ -27,6 +27,7 @@ from custom_components.tibber_prices.config_flow_handlers.schemas import ( get_best_price_schema, get_options_init_schema, get_peak_price_schema, + get_price_level_schema, get_price_rating_schema, get_price_trend_schema, get_reauth_confirm_schema, @@ -56,6 +57,7 @@ __all__ = [ "get_best_price_schema", "get_options_init_schema", "get_peak_price_schema", + "get_price_level_schema", "get_price_rating_schema", "get_price_trend_schema", "get_reauth_confirm_schema", diff --git a/custom_components/tibber_prices/config_flow_handlers/options_flow.py b/custom_components/tibber_prices/config_flow_handlers/options_flow.py index 5ea073a..c1ef500 100644 --- a/custom_components/tibber_prices/config_flow_handlers/options_flow.py +++ b/custom_components/tibber_prices/config_flow_handlers/options_flow.py @@ -15,6 +15,7 @@ from custom_components.tibber_prices.config_flow_handlers.schemas import ( get_display_settings_schema, get_options_init_schema, get_peak_price_schema, + get_price_level_schema, get_price_rating_schema, get_price_trend_schema, get_reset_to_defaults_schema, @@ -191,6 +192,7 @@ class TibberPricesOptionsFlowHandler(OptionsFlow): "general_settings", "display_settings", "current_interval_price_rating", + "price_level", "volatility", "best_price", "peak_price", @@ -329,6 +331,25 @@ class TibberPricesOptionsFlowHandler(OptionsFlow): 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: """Configure best price period settings.""" errors: dict[str, str] = {} diff --git a/custom_components/tibber_prices/config_flow_handlers/schemas.py b/custom_components/tibber_prices/config_flow_handlers/schemas.py index 1df45d3..0adfd3d 100644 --- a/custom_components/tibber_prices/config_flow_handlers/schemas.py +++ b/custom_components/tibber_prices/config_flow_handlers/schemas.py @@ -28,6 +28,7 @@ from custom_components.tibber_prices.const import ( CONF_PEAK_PRICE_MIN_DISTANCE_FROM_AVG, CONF_PEAK_PRICE_MIN_LEVEL, CONF_PEAK_PRICE_MIN_PERIOD_LENGTH, + CONF_PRICE_LEVEL_GAP_TOLERANCE, CONF_PRICE_RATING_GAP_TOLERANCE, CONF_PRICE_RATING_HYSTERESIS, 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_LEVEL, DEFAULT_PEAK_PRICE_MIN_PERIOD_LENGTH, + DEFAULT_PRICE_LEVEL_GAP_TOLERANCE, DEFAULT_PRICE_RATING_GAP_TOLERANCE, DEFAULT_PRICE_RATING_HYSTERESIS, DEFAULT_PRICE_RATING_THRESHOLD_HIGH, @@ -77,6 +79,7 @@ from custom_components.tibber_prices.const import ( MAX_GAP_COUNT, MAX_MIN_PERIOD_LENGTH, MAX_MIN_PERIODS, + MAX_PRICE_LEVEL_GAP_TOLERANCE, MAX_PRICE_RATING_GAP_TOLERANCE, MAX_PRICE_RATING_HYSTERESIS, MAX_PRICE_RATING_THRESHOLD_HIGH, @@ -89,6 +92,7 @@ from custom_components.tibber_prices.const import ( MAX_VOLATILITY_THRESHOLD_VERY_HIGH, MIN_GAP_COUNT, MIN_PERIOD_LENGTH, + MIN_PRICE_LEVEL_GAP_TOLERANCE, MIN_PRICE_RATING_GAP_TOLERANCE, MIN_PRICE_RATING_HYSTERESIS, 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: """Return schema for volatility thresholds configuration.""" return vol.Schema( diff --git a/custom_components/tibber_prices/const.py b/custom_components/tibber_prices/const.py index 0326d54..ea61b66 100644 --- a/custom_components/tibber_prices/const.py +++ b/custom_components/tibber_prices/const.py @@ -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_HYSTERESIS = "price_rating_hysteresis" 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_PRICE_TREND_THRESHOLD_RISING = "price_trend_threshold_rising" 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_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_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_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) @@ -139,6 +141,8 @@ MIN_PRICE_RATING_HYSTERESIS = 0.0 # Minimum hysteresis (0 = disabled) MAX_PRICE_RATING_HYSTERESIS = 5.0 # Maximum hysteresis (5% band) MIN_PRICE_RATING_GAP_TOLERANCE = 0 # Minimum gap tolerance (0 = disabled) 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 # 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_HYSTERESIS: DEFAULT_PRICE_RATING_HYSTERESIS, 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) CONF_VOLATILITY_THRESHOLD_MODERATE: DEFAULT_VOLATILITY_THRESHOLD_MODERATE, CONF_VOLATILITY_THRESHOLD_HIGH: DEFAULT_VOLATILITY_THRESHOLD_HIGH, diff --git a/custom_components/tibber_prices/coordinator/core.py b/custom_components/tibber_prices/coordinator/core.py index 7be448c..168e884 100644 --- a/custom_components/tibber_prices/coordinator/core.py +++ b/custom_components/tibber_prices/coordinator/core.py @@ -264,7 +264,7 @@ class TibberPricesDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): async def _handle_options_update(self, _hass: HomeAssistant, _config_entry: ConfigEntry) -> None: """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._period_calculator.invalidate_config_cache() @@ -272,12 +272,10 @@ class TibberPricesDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): # This updates rating_levels, volatility, and period calculations # without needing to fetch new data from the API if self._cached_price_data: - self._log("debug", "Re-transforming cached data with new configuration") self.data = self._transform_data(self._cached_price_data) - # Notify all listeners about the updated data self.async_update_listeners() else: - self._log("warning", "No cached data available to re-transform") + self._log("debug", "No cached data to re-transform") @callback def async_add_time_sensitive_listener(self, update_callback: TimeServiceCallback) -> CALLBACK_TYPE: diff --git a/custom_components/tibber_prices/coordinator/data_transformation.py b/custom_components/tibber_prices/coordinator/data_transformation.py index 6aa1852..2c62347 100644 --- a/custom_components/tibber_prices/coordinator/data_transformation.py +++ b/custom_components/tibber_prices/coordinator/data_transformation.py @@ -2,6 +2,7 @@ from __future__ import annotations +import copy import logging from typing import TYPE_CHECKING, Any @@ -49,7 +50,12 @@ class TibberPricesDataTransformer: getattr(_LOGGER, level)(prefixed_message, *args, **kwargs) 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 {} return { "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: - """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 = 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]: """ @@ -89,6 +117,7 @@ class TibberPricesDataTransformer: config = { "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": { "moderate": options.get(_const.CONF_VOLATILITY_THRESHOLD_MODERATE, 15.0), @@ -155,8 +184,9 @@ class TibberPricesDataTransformer: # Configuration changed - must retransform current_config = self._get_current_transformation_config() - if current_config != self._last_transformation_config: - self._log("debug", "Configuration changed, retransforming data") + config_changed = current_config != self._last_transformation_config + + if config_changed: return True # Check for midnight turnover @@ -181,10 +211,17 @@ class TibberPricesDataTransformer: source_data_timestamp = raw_data.get("timestamp") # Return cached transformed data if no retransformation needed - if ( - not self._should_retransform_data(current_time, source_data_timestamp) - and self._cached_transformed_data is not None - ): + should_retransform = self._should_retransform_data(current_time, source_data_timestamp) + has_cache = 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)") return self._cached_transformed_data @@ -192,7 +229,10 @@ class TibberPricesDataTransformer: # Extract data from single-home structure 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") if not all_intervals: @@ -209,13 +249,16 @@ class TibberPricesDataTransformer: # Enrich price info dynamically with calculated differences and rating levels # (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( all_intervals, threshold_low=thresholds["low"], threshold_high=thresholds["high"], hysteresis=float(thresholds["hysteresis"]), gap_tolerance=int(thresholds["gap_tolerance"]), + level_gap_tolerance=level_gap_tolerance, time=self.time, ) diff --git a/custom_components/tibber_prices/coordinator/period_handlers/level_filtering.py b/custom_components/tibber_prices/coordinator/period_handlers/level_filtering.py index d26f3e8..bcd783b 100644 --- a/custom_components/tibber_prices/coordinator/period_handlers/level_filtering.py +++ b/custom_components/tibber_prices/coordinator/period_handlers/level_filtering.py @@ -181,7 +181,7 @@ def check_interval_criteria( if scale_factor < SCALE_FACTOR_WARNING_THRESHOLD: import logging # noqa: PLC0415 - _LOGGER = logging.getLogger(__name__) # noqa: N806 + _LOGGER = logging.getLogger(f"{__name__}.details") # noqa: N806 _LOGGER.debug( "High flex %.1f%% detected: Reducing min_distance %.1f%% → %.1f%% (scale %.2f)", flex_abs * 100, diff --git a/custom_components/tibber_prices/coordinator/period_handlers/period_overlap.py b/custom_components/tibber_prices/coordinator/period_handlers/period_overlap.py index 7457c3b..079f62a 100644 --- a/custom_components/tibber_prices/coordinator/period_handlers/period_overlap.py +++ b/custom_components/tibber_prices/coordinator/period_handlers/period_overlap.py @@ -105,7 +105,7 @@ def merge_adjacent_periods(period1: dict, period2: dict) -> dict: "period2_end": period2["end"].isoformat(), } - _LOGGER.debug( + _LOGGER_DETAILS.debug( "%sMerged periods: %s-%s + %s-%s → %s-%s (duration: %d min)", INDENT_L2, 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) """ - _LOGGER.debug( + _LOGGER_DETAILS.debug( "%sresolve_period_overlaps called: existing=%d, new=%d", INDENT_L0, len(existing_periods), @@ -175,7 +175,7 @@ def resolve_period_overlaps( and abs((relaxed_end - existing["end"]).total_seconds()) < tolerance_seconds ): is_duplicate = True - _LOGGER.debug( + _LOGGER_DETAILS.debug( "%sSkipping duplicate period %s-%s (already exists)", INDENT_L1, relaxed_start.strftime("%H:%M"), @@ -198,7 +198,7 @@ def resolve_period_overlaps( if is_adjacent or is_overlapping: periods_to_merge.append((idx, existing)) - _LOGGER.debug( + _LOGGER_DETAILS.debug( "%sPeriod %s-%s %s with existing period %s-%s", INDENT_L1, relaxed_start.strftime("%H:%M"), @@ -212,7 +212,7 @@ def resolve_period_overlaps( # No merge needed - add as new period merged.append(relaxed) periods_added += 1 - _LOGGER.debug( + _LOGGER_DETAILS.debug( "%sAdded new period %s-%s (no overlap/adjacency)", INDENT_L1, relaxed_start.strftime("%H:%M"), diff --git a/custom_components/tibber_prices/translations/de.json b/custom_components/tibber_prices/translations/de.json index 352df74..d79bbd6 100644 --- a/custom_components/tibber_prices/translations/de.json +++ b/custom_components/tibber_prices/translations/de.json @@ -136,6 +136,7 @@ "general_settings": "⚙️ Allgemeine Einstellungen", "display_settings": "💱 Währungsanzeige", "current_interval_price_rating": "📊 Preisbewertung", + "price_level": "🏷️ Preisniveau", "volatility": "💨 Preis-Volatilität", "best_price": "💚 Bestpreis", "peak_price": "🔴 Spitzenpreis", @@ -320,6 +321,17 @@ "confirm_reset": "Ja, alles auf Werkseinstellungen 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": { diff --git a/custom_components/tibber_prices/translations/en.json b/custom_components/tibber_prices/translations/en.json index 8d2726d..f2c3ad8 100644 --- a/custom_components/tibber_prices/translations/en.json +++ b/custom_components/tibber_prices/translations/en.json @@ -136,6 +136,7 @@ "general_settings": "⚙️ General Settings", "display_settings": "💱 Currency Display", "current_interval_price_rating": "📊 Price Rating", + "price_level": "🏷️ Price Level", "volatility": "💨 Price Volatility", "best_price": "💚 Best Price Period", "peak_price": "🔴 Peak Price Period", @@ -186,6 +187,17 @@ }, "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": { "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---", diff --git a/custom_components/tibber_prices/translations/nb.json b/custom_components/tibber_prices/translations/nb.json index 6e8755c..363ec4e 100644 --- a/custom_components/tibber_prices/translations/nb.json +++ b/custom_components/tibber_prices/translations/nb.json @@ -136,6 +136,7 @@ "general_settings": "⚙️ Generelle innstillinger", "display_settings": "💱 Valutavisning", "current_interval_price_rating": "📊 Prisvurdering", + "price_level": "🏷️ Prisnivå", "volatility": "💨 Prisvolatilitet", "best_price": "💚 Beste prisperiode", "peak_price": "🔴 Toppprisperiode", @@ -320,6 +321,17 @@ "confirm_reset": "Ja, tilbakestill alt til standard" }, "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": { diff --git a/custom_components/tibber_prices/translations/nl.json b/custom_components/tibber_prices/translations/nl.json index f0257f1..226b9f3 100644 --- a/custom_components/tibber_prices/translations/nl.json +++ b/custom_components/tibber_prices/translations/nl.json @@ -136,6 +136,7 @@ "general_settings": "⚙️ Algemene Instellingen", "display_settings": "💱 Valuta Weergave", "current_interval_price_rating": "📊 Prijsbeoordeling", + "price_level": "🏷️ Prijsniveau", "volatility": "💨 Prijsvolatiliteit", "best_price": "💚 Beste Prijs Periode", "peak_price": "🔴 Piekprijs Periode", @@ -320,6 +321,17 @@ "confirm_reset": "Ja, reset alles naar standaardwaarden" }, "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": { @@ -1157,4 +1169,4 @@ } }, "title": "Tibber Prijsinformatie & Beoordelingen" -} \ No newline at end of file +} diff --git a/custom_components/tibber_prices/translations/sv.json b/custom_components/tibber_prices/translations/sv.json index ec576ca..a9422fb 100644 --- a/custom_components/tibber_prices/translations/sv.json +++ b/custom_components/tibber_prices/translations/sv.json @@ -136,6 +136,7 @@ "general_settings": "⚙️ Allmänna inställningar", "display_settings": "💱 Valutavisning", "current_interval_price_rating": "📊 Prisbetyg", + "price_level": "🏷️ Prisnivå", "volatility": "💨 Prisvolatilitet", "best_price": "💚 Bästa Prisperiod", "peak_price": "🔴 Topprisperiod", @@ -320,6 +321,17 @@ "confirm_reset": "Ja, återställ allt till standard" }, "submit": "Återställ nu" + }, + "price_level": { + "title": "��️ 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": { diff --git a/custom_components/tibber_prices/utils/price.py b/custom_components/tibber_prices/utils/price.py index 5bf2fd8..4eff217 100644 --- a/custom_components/tibber_prices/utils/price.py +++ b/custom_components/tibber_prices/utils/price.py @@ -11,6 +11,7 @@ if TYPE_CHECKING: from custom_components.tibber_prices.coordinator.time_service import TibberPricesTimeService from custom_components.tibber_prices.const import ( + DEFAULT_PRICE_LEVEL_GAP_TOLERANCE, DEFAULT_PRICE_RATING_GAP_TOLERANCE, DEFAULT_PRICE_RATING_HYSTERESIS, DEFAULT_VOLATILITY_THRESHOLD_HIGH, @@ -360,6 +361,39 @@ def _build_rating_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( blocks: list[tuple[int, int, str, 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) +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( blocks: list[tuple[int, int, str, int]], rated_intervals: list[tuple[int, dict[str, Any], str]], @@ -535,6 +638,63 @@ def _merge_small_blocks( 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 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, hysteresis: float | 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 ) -> 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 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 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 @@ -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_high: High threshold percentage for rating_level (defaults to 10) 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) 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 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 + level_gap_tolerance = level_gap_tolerance if level_gap_tolerance is not None else DEFAULT_PRICE_LEVEL_GAP_TOLERANCE if not 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: _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