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
|
||||
# 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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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] = {}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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---",
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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": "<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": {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue