From 53e73a7fda2b61d18cb4770c545dd552aca5158b Mon Sep 17 00:00:00 2001 From: Julian Pawlowski Date: Wed, 12 Nov 2025 13:20:14 +0000 Subject: [PATCH] feat(period-calc): adaptive defaults + remove volatility filter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Major improvements to period calculation with smarter defaults and simplified configuration: **Adaptive Defaults:** - ENABLE_MIN_PERIODS: true (was false) - Always try to find periods - MIN_PERIODS target: 2 periods/day (ensures coverage) - BEST_PRICE_MAX_LEVEL: "cheap" (was "any") - Prefer genuinely cheap - PEAK_PRICE_MIN_LEVEL: "expensive" (was "any") - Prefer genuinely expensive - GAP_TOLERANCE: 1 (was 0) - Allow 1-level deviations in sequences - MIN_DISTANCE_FROM_AVG: 5% (was 2%) - Ensure significance - PEAK_PRICE_MIN_PERIOD_LENGTH: 30min (was 60min) - More responsive - PEAK_PRICE_FLEX: -20% (was -15%) - Better peak detection **Volatility Filter Removal:** - Removed CONF_BEST_PRICE_MIN_VOLATILITY from const.py - Removed CONF_PEAK_PRICE_MIN_VOLATILITY from const.py - Removed volatility filter UI controls from config_flow.py - Removed filter_periods_by_volatility() calls from coordinator.py - Updated all 5 translations (de, en, nb, nl, sv) **Period Calculation Logic:** - Level filter now integrated into _build_periods() (applied during interval qualification, not as post-filter) - Gap tolerance implemented via _check_level_with_gap_tolerance() - Short periods (<1.5h) use strict filtering (no gap tolerance) - Relaxation now passes level_filter + gap_count directly to PeriodConfig - show_periods check skipped when relaxation enabled (relaxation tries "any" as fallback) **Documentation:** - Complete rewrite of docs/user/period-calculation.md: * Visual examples with timelines * Step-by-step explanation of 4-step process * Configuration scenarios (5 common use cases) * Troubleshooting section with specific fixes * Advanced topics (per-day independence, early stop, etc.) - Updated README.md: "volatility" → "distance from average" Impact: Periods now reliably appear on most days with meaningful quality filters. Users get warned about expensive periods and notified about cheap opportunities without manual tuning. Relaxation ensures coverage while keeping filters as strict as possible. Breaking change: Volatility filter removed (was never a critical feature, often confused users). Existing configs continue to work (removed keys are simply ignored). --- README.md | 2 +- .../tibber_prices/config_flow.py | 31 - custom_components/tibber_prices/const.py | 34 +- .../tibber_prices/coordinator.py | 67 +- .../tibber_prices/period_utils.py | 231 +++- .../tibber_prices/translations/de.json | 4 - .../tibber_prices/translations/en.json | 4 - .../tibber_prices/translations/nb.json | 6 +- .../tibber_prices/translations/nl.json | 6 +- .../tibber_prices/translations/sv.json | 6 +- docs/user/period-calculation.md | 1003 +++++++++++++---- 11 files changed, 1054 insertions(+), 340 deletions(-) diff --git a/README.md b/README.md index f6d8d56..839e3ad 100644 --- a/README.md +++ b/README.md @@ -175,7 +175,7 @@ automation: entity_id: switch.dishwasher ``` -> **Learn more:** The [period calculation guide](docs/user/period-calculation.md) explains how Best/Peak Price periods are identified and how you can configure filters (flexibility, minimum volatility, price level filters with gap tolerance). +> **Learn more:** The [period calculation guide](docs/user/period-calculation.md) explains how Best/Peak Price periods are identified and how you can configure filters (flexibility, minimum distance from average, price level filters with gap tolerance). ### Notify on Extremely High Prices diff --git a/custom_components/tibber_prices/config_flow.py b/custom_components/tibber_prices/config_flow.py index d5a7be9..3f05f69 100644 --- a/custom_components/tibber_prices/config_flow.py +++ b/custom_components/tibber_prices/config_flow.py @@ -45,7 +45,6 @@ from .const import ( CONF_BEST_PRICE_MAX_LEVEL_GAP_COUNT, CONF_BEST_PRICE_MIN_DISTANCE_FROM_AVG, CONF_BEST_PRICE_MIN_PERIOD_LENGTH, - CONF_BEST_PRICE_MIN_VOLATILITY, CONF_ENABLE_MIN_PERIODS_BEST, CONF_ENABLE_MIN_PERIODS_PEAK, CONF_EXTENDED_DESCRIPTIONS, @@ -56,7 +55,6 @@ from .const import ( CONF_PEAK_PRICE_MIN_DISTANCE_FROM_AVG, CONF_PEAK_PRICE_MIN_LEVEL, CONF_PEAK_PRICE_MIN_PERIOD_LENGTH, - CONF_PEAK_PRICE_MIN_VOLATILITY, CONF_PRICE_RATING_THRESHOLD_HIGH, CONF_PRICE_RATING_THRESHOLD_LOW, CONF_PRICE_TREND_THRESHOLD_FALLING, @@ -71,7 +69,6 @@ from .const import ( DEFAULT_BEST_PRICE_MAX_LEVEL_GAP_COUNT, DEFAULT_BEST_PRICE_MIN_DISTANCE_FROM_AVG, DEFAULT_BEST_PRICE_MIN_PERIOD_LENGTH, - DEFAULT_BEST_PRICE_MIN_VOLATILITY, DEFAULT_ENABLE_MIN_PERIODS_BEST, DEFAULT_ENABLE_MIN_PERIODS_PEAK, DEFAULT_EXTENDED_DESCRIPTIONS, @@ -82,7 +79,6 @@ from .const import ( DEFAULT_PEAK_PRICE_MIN_DISTANCE_FROM_AVG, DEFAULT_PEAK_PRICE_MIN_LEVEL, DEFAULT_PEAK_PRICE_MIN_PERIOD_LENGTH, - DEFAULT_PEAK_PRICE_MIN_VOLATILITY, DEFAULT_PRICE_RATING_THRESHOLD_HIGH, DEFAULT_PRICE_RATING_THRESHOLD_LOW, DEFAULT_PRICE_TREND_THRESHOLD_FALLING, @@ -94,7 +90,6 @@ from .const import ( DEFAULT_VOLATILITY_THRESHOLD_VERY_HIGH, DOMAIN, LOGGER, - MIN_VOLATILITY_FOR_PERIODS_OPTIONS, PEAK_PRICE_MIN_LEVEL_OPTIONS, ) @@ -657,19 +652,6 @@ class TibberPricesOptionsFlowHandler(OptionsFlow): mode=NumberSelectorMode.SLIDER, ), ), - vol.Optional( - CONF_BEST_PRICE_MIN_VOLATILITY, - default=self.config_entry.options.get( - CONF_BEST_PRICE_MIN_VOLATILITY, - DEFAULT_BEST_PRICE_MIN_VOLATILITY, - ), - ): SelectSelector( - SelectSelectorConfig( - options=MIN_VOLATILITY_FOR_PERIODS_OPTIONS, - mode=SelectSelectorMode.DROPDOWN, - translation_key="volatility", - ), - ), vol.Optional( CONF_BEST_PRICE_MAX_LEVEL, default=self.config_entry.options.get( @@ -805,19 +787,6 @@ class TibberPricesOptionsFlowHandler(OptionsFlow): mode=NumberSelectorMode.SLIDER, ), ), - vol.Optional( - CONF_PEAK_PRICE_MIN_VOLATILITY, - default=self.config_entry.options.get( - CONF_PEAK_PRICE_MIN_VOLATILITY, - DEFAULT_PEAK_PRICE_MIN_VOLATILITY, - ), - ): SelectSelector( - SelectSelectorConfig( - options=MIN_VOLATILITY_FOR_PERIODS_OPTIONS, - mode=SelectSelectorMode.DROPDOWN, - translation_key="volatility", - ), - ), vol.Optional( CONF_PEAK_PRICE_MIN_LEVEL, default=self.config_entry.options.get( diff --git a/custom_components/tibber_prices/const.py b/custom_components/tibber_prices/const.py index ac0d275..02ed28c 100644 --- a/custom_components/tibber_prices/const.py +++ b/custom_components/tibber_prices/const.py @@ -31,8 +31,6 @@ CONF_PRICE_TREND_THRESHOLD_FALLING = "price_trend_threshold_falling" CONF_VOLATILITY_THRESHOLD_MODERATE = "volatility_threshold_moderate" CONF_VOLATILITY_THRESHOLD_HIGH = "volatility_threshold_high" CONF_VOLATILITY_THRESHOLD_VERY_HIGH = "volatility_threshold_very_high" -CONF_BEST_PRICE_MIN_VOLATILITY = "best_price_min_volatility" -CONF_PEAK_PRICE_MIN_VOLATILITY = "peak_price_min_volatility" CONF_BEST_PRICE_MAX_LEVEL = "best_price_max_level" CONF_PEAK_PRICE_MIN_LEVEL = "peak_price_min_level" CONF_BEST_PRICE_MAX_LEVEL_GAP_COUNT = "best_price_max_level_gap_count" @@ -50,11 +48,11 @@ ATTRIBUTION = "Data provided by Tibber" DEFAULT_NAME = "Tibber Price Information & Ratings" DEFAULT_EXTENDED_DESCRIPTIONS = False DEFAULT_BEST_PRICE_FLEX = 15 # 15% flexibility for best price (user-facing, percent) -DEFAULT_PEAK_PRICE_FLEX = -15 # 15% flexibility for peak price (user-facing, percent) -DEFAULT_BEST_PRICE_MIN_DISTANCE_FROM_AVG = 2 # 2% minimum distance from daily average for best price -DEFAULT_PEAK_PRICE_MIN_DISTANCE_FROM_AVG = 2 # 2% minimum distance from daily average for peak price +DEFAULT_PEAK_PRICE_FLEX = -20 # 20% flexibility for peak price (user-facing, percent) +DEFAULT_BEST_PRICE_MIN_DISTANCE_FROM_AVG = 5 # 5% minimum distance from daily average (ensures significance) +DEFAULT_PEAK_PRICE_MIN_DISTANCE_FROM_AVG = 5 # 5% minimum distance from daily average (ensures significance) DEFAULT_BEST_PRICE_MIN_PERIOD_LENGTH = 60 # 60 minutes minimum period length for best price (user-facing, minutes) -DEFAULT_PEAK_PRICE_MIN_PERIOD_LENGTH = 60 # 60 minutes minimum period length for peak price (user-facing, minutes) +DEFAULT_PEAK_PRICE_MIN_PERIOD_LENGTH = 30 # 30 minutes minimum period length for peak price (user-facing, minutes) DEFAULT_PRICE_RATING_THRESHOLD_LOW = -10 # Default rating threshold low percentage DEFAULT_PRICE_RATING_THRESHOLD_HIGH = 10 # Default rating threshold high percentage DEFAULT_PRICE_TREND_THRESHOLD_RISING = 5 # Default trend threshold for rising prices (%) @@ -62,17 +60,15 @@ DEFAULT_PRICE_TREND_THRESHOLD_FALLING = -5 # Default trend threshold for fallin DEFAULT_VOLATILITY_THRESHOLD_MODERATE = 5.0 # Default threshold for MODERATE volatility (ct/øre) DEFAULT_VOLATILITY_THRESHOLD_HIGH = 15.0 # Default threshold for HIGH volatility (ct/øre) DEFAULT_VOLATILITY_THRESHOLD_VERY_HIGH = 30.0 # Default threshold for VERY_HIGH volatility (ct/øre) -DEFAULT_BEST_PRICE_MIN_VOLATILITY = "low" # Show best price at any volatility (optimization always useful) -DEFAULT_PEAK_PRICE_MIN_VOLATILITY = "low" # Always show peak price (warning relevant even at low spreads) -DEFAULT_BEST_PRICE_MAX_LEVEL = "any" # Default: show best price periods regardless of price level -DEFAULT_PEAK_PRICE_MIN_LEVEL = "any" # Default: show peak price periods regardless of price level -DEFAULT_BEST_PRICE_MAX_LEVEL_GAP_COUNT = 0 # Default: no tolerance for level gaps (strict filtering) -DEFAULT_PEAK_PRICE_MAX_LEVEL_GAP_COUNT = 0 # Default: no tolerance for level gaps (strict filtering) +DEFAULT_BEST_PRICE_MAX_LEVEL = "cheap" # Default: prefer genuinely cheap periods, relax to "any" if needed +DEFAULT_PEAK_PRICE_MIN_LEVEL = "expensive" # Default: prefer genuinely expensive periods, relax to "any" if needed +DEFAULT_BEST_PRICE_MAX_LEVEL_GAP_COUNT = 1 # Default: allow 1 level gap (e.g., CHEAP→NORMAL→CHEAP stays together) +DEFAULT_PEAK_PRICE_MAX_LEVEL_GAP_COUNT = 1 # Default: allow 1 level gap for peak price periods MIN_INTERVALS_FOR_GAP_TOLERANCE = 6 # Minimum period length (in 15-min intervals = 1.5h) required for gap tolerance -DEFAULT_ENABLE_MIN_PERIODS_BEST = False # Default: minimum periods feature disabled for best price +DEFAULT_ENABLE_MIN_PERIODS_BEST = True # Default: minimum periods feature enabled for best price DEFAULT_MIN_PERIODS_BEST = 2 # Default: require at least 2 best price periods (when enabled) DEFAULT_RELAXATION_STEP_BEST = 25 # Default: 25% of original threshold per relaxation step for best price -DEFAULT_ENABLE_MIN_PERIODS_PEAK = False # Default: minimum periods feature disabled for peak price +DEFAULT_ENABLE_MIN_PERIODS_PEAK = True # Default: minimum periods feature enabled for peak price DEFAULT_MIN_PERIODS_PEAK = 2 # Default: require at least 2 peak price periods (when enabled) DEFAULT_RELAXATION_STEP_PEAK = 25 # Default: 25% of original threshold per relaxation step for peak price @@ -193,15 +189,7 @@ VOLATILITY_OPTIONS = [ VOLATILITY_VERY_HIGH.lower(), ] -# Valid options for minimum volatility filter for periods -MIN_VOLATILITY_FOR_PERIODS_OPTIONS = [ - VOLATILITY_LOW.lower(), # Show at any volatility (≥0ct spread) - no filter - VOLATILITY_MODERATE.lower(), # Only show periods when volatility ≥ MODERATE (≥5ct) - VOLATILITY_HIGH.lower(), # Only show periods when volatility ≥ HIGH (≥15ct) - VOLATILITY_VERY_HIGH.lower(), # Only show periods when volatility ≥ VERY_HIGH (≥30ct) -] - -# Valid options for best price maximum level filter (AND-linked with volatility filter) +# Valid options for best price maximum level filter # Sorted from cheap to expensive: user selects "up to how expensive" BEST_PRICE_MAX_LEVEL_OPTIONS = [ "any", # No filter, allow all price levels diff --git a/custom_components/tibber_prices/coordinator.py b/custom_components/tibber_prices/coordinator.py index ddc6c8a..1decedb 100644 --- a/custom_components/tibber_prices/coordinator.py +++ b/custom_components/tibber_prices/coordinator.py @@ -30,7 +30,6 @@ from .const import ( CONF_BEST_PRICE_MAX_LEVEL_GAP_COUNT, CONF_BEST_PRICE_MIN_DISTANCE_FROM_AVG, CONF_BEST_PRICE_MIN_PERIOD_LENGTH, - CONF_BEST_PRICE_MIN_VOLATILITY, CONF_ENABLE_MIN_PERIODS_BEST, CONF_ENABLE_MIN_PERIODS_PEAK, CONF_MIN_PERIODS_BEST, @@ -40,7 +39,6 @@ from .const import ( CONF_PEAK_PRICE_MIN_DISTANCE_FROM_AVG, CONF_PEAK_PRICE_MIN_LEVEL, CONF_PEAK_PRICE_MIN_PERIOD_LENGTH, - CONF_PEAK_PRICE_MIN_VOLATILITY, CONF_PRICE_RATING_THRESHOLD_HIGH, CONF_PRICE_RATING_THRESHOLD_LOW, CONF_RELAXATION_STEP_BEST, @@ -53,7 +51,6 @@ from .const import ( DEFAULT_BEST_PRICE_MAX_LEVEL_GAP_COUNT, DEFAULT_BEST_PRICE_MIN_DISTANCE_FROM_AVG, DEFAULT_BEST_PRICE_MIN_PERIOD_LENGTH, - DEFAULT_BEST_PRICE_MIN_VOLATILITY, DEFAULT_ENABLE_MIN_PERIODS_BEST, DEFAULT_ENABLE_MIN_PERIODS_PEAK, DEFAULT_MIN_PERIODS_BEST, @@ -63,7 +60,6 @@ from .const import ( DEFAULT_PEAK_PRICE_MIN_DISTANCE_FROM_AVG, DEFAULT_PEAK_PRICE_MIN_LEVEL, DEFAULT_PEAK_PRICE_MIN_PERIOD_LENGTH, - DEFAULT_PEAK_PRICE_MIN_VOLATILITY, DEFAULT_PRICE_RATING_THRESHOLD_HIGH, DEFAULT_PRICE_RATING_THRESHOLD_LOW, DEFAULT_RELAXATION_STEP_BEST, @@ -78,7 +74,6 @@ from .const import ( from .period_utils import ( PeriodConfig, calculate_periods_with_relaxation, - filter_periods_by_volatility, ) from .price_utils import ( enrich_price_info_with_differences, @@ -908,6 +903,10 @@ class TibberPricesDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): # Periods shorter than MIN_INTERVALS_FOR_GAP_TOLERANCE (1.5h) use strict filtering if interval_count < MIN_INTERVALS_FOR_GAP_TOLERANCE: + _LOGGER.debug( + "Using strict filtering for short period (%d intervals)", + interval_count, + ) return self._check_short_period_strict(today_intervals, level_order, reverse_sort=reverse_sort) # Try normal gap tolerance check first @@ -1143,14 +1142,19 @@ class TibberPricesDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): DEFAULT_VOLATILITY_THRESHOLD_VERY_HIGH, ) - # Check if best price periods should be shown (apply filters) - show_best_price = self._should_show_periods(price_info, reverse_sort=False) if all_prices else False - # Get relaxation configuration for best price enable_relaxation_best = self.config_entry.options.get( CONF_ENABLE_MIN_PERIODS_BEST, DEFAULT_ENABLE_MIN_PERIODS_BEST, ) + + # Check if best price periods should be shown + # If relaxation is enabled, always calculate (relaxation will try "any" filter) + # If relaxation is disabled, apply level filter check + if enable_relaxation_best: + show_best_price = bool(all_prices) + else: + show_best_price = self._should_show_periods(price_info, reverse_sort=False) if all_prices else False min_periods_best = self.config_entry.options.get( CONF_MIN_PERIODS_BEST, DEFAULT_MIN_PERIODS_BEST, @@ -1163,6 +1167,15 @@ class TibberPricesDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): # Calculate best price periods (or return empty if filtered) if show_best_price: best_config = self._get_period_config(reverse_sort=False) + # Get level filter configuration + max_level_best = self.config_entry.options.get( + CONF_BEST_PRICE_MAX_LEVEL, + DEFAULT_BEST_PRICE_MAX_LEVEL, + ) + gap_count_best = self.config_entry.options.get( + CONF_BEST_PRICE_MAX_LEVEL_GAP_COUNT, + DEFAULT_BEST_PRICE_MAX_LEVEL_GAP_COUNT, + ) best_period_config = PeriodConfig( reverse_sort=False, flex=best_config["flex"], @@ -1173,6 +1186,8 @@ class TibberPricesDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): threshold_volatility_moderate=threshold_volatility_moderate, threshold_volatility_high=threshold_volatility_high, threshold_volatility_very_high=threshold_volatility_very_high, + level_filter=max_level_best, + gap_count=gap_count_best, ) best_periods, best_relaxation = calculate_periods_with_relaxation( all_prices, @@ -1186,13 +1201,6 @@ class TibberPricesDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): level_override=lvl, ), ) - - # Apply period-level volatility filter (after calculation) - min_volatility_best = self.config_entry.options.get( - CONF_BEST_PRICE_MIN_VOLATILITY, - DEFAULT_BEST_PRICE_MIN_VOLATILITY, - ) - best_periods = filter_periods_by_volatility(best_periods, min_volatility_best) else: best_periods = { "periods": [], @@ -1201,14 +1209,19 @@ class TibberPricesDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): } best_relaxation = {"relaxation_active": False, "relaxation_attempted": False} - # Check if peak price periods should be shown (apply filters) - show_peak_price = self._should_show_periods(price_info, reverse_sort=True) if all_prices else False - # Get relaxation configuration for peak price enable_relaxation_peak = self.config_entry.options.get( CONF_ENABLE_MIN_PERIODS_PEAK, DEFAULT_ENABLE_MIN_PERIODS_PEAK, ) + + # Check if peak price periods should be shown + # If relaxation is enabled, always calculate (relaxation will try "any" filter) + # If relaxation is disabled, apply level filter check + if enable_relaxation_peak: + show_peak_price = bool(all_prices) + else: + show_peak_price = self._should_show_periods(price_info, reverse_sort=True) if all_prices else False min_periods_peak = self.config_entry.options.get( CONF_MIN_PERIODS_PEAK, DEFAULT_MIN_PERIODS_PEAK, @@ -1221,6 +1234,15 @@ class TibberPricesDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): # Calculate peak price periods (or return empty if filtered) if show_peak_price: peak_config = self._get_period_config(reverse_sort=True) + # Get level filter configuration + min_level_peak = self.config_entry.options.get( + CONF_PEAK_PRICE_MIN_LEVEL, + DEFAULT_PEAK_PRICE_MIN_LEVEL, + ) + gap_count_peak = self.config_entry.options.get( + CONF_PEAK_PRICE_MAX_LEVEL_GAP_COUNT, + DEFAULT_PEAK_PRICE_MAX_LEVEL_GAP_COUNT, + ) peak_period_config = PeriodConfig( reverse_sort=True, flex=peak_config["flex"], @@ -1231,6 +1253,8 @@ class TibberPricesDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): threshold_volatility_moderate=threshold_volatility_moderate, threshold_volatility_high=threshold_volatility_high, threshold_volatility_very_high=threshold_volatility_very_high, + level_filter=min_level_peak, + gap_count=gap_count_peak, ) peak_periods, peak_relaxation = calculate_periods_with_relaxation( all_prices, @@ -1244,13 +1268,6 @@ class TibberPricesDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): level_override=lvl, ), ) - - # Apply period-level volatility filter (after calculation) - min_volatility_peak = self.config_entry.options.get( - CONF_PEAK_PRICE_MIN_VOLATILITY, - DEFAULT_PEAK_PRICE_MIN_VOLATILITY, - ) - peak_periods = filter_periods_by_volatility(peak_periods, min_volatility_peak) else: peak_periods = { "periods": [], diff --git a/custom_components/tibber_prices/period_utils.py b/custom_components/tibber_prices/period_utils.py index 9dfb5cd..c4c8c74 100644 --- a/custom_components/tibber_prices/period_utils.py +++ b/custom_components/tibber_prices/period_utils.py @@ -17,6 +17,7 @@ from .const import ( DEFAULT_VOLATILITY_THRESHOLD_HIGH, DEFAULT_VOLATILITY_THRESHOLD_MODERATE, DEFAULT_VOLATILITY_THRESHOLD_VERY_HIGH, + PRICE_LEVEL_MAPPING, ) from .price_utils import ( aggregate_period_levels, @@ -49,6 +50,8 @@ class PeriodConfig(NamedTuple): threshold_volatility_moderate: float = DEFAULT_VOLATILITY_THRESHOLD_MODERATE threshold_volatility_high: float = DEFAULT_VOLATILITY_THRESHOLD_HIGH threshold_volatility_very_high: float = DEFAULT_VOLATILITY_THRESHOLD_VERY_HIGH + level_filter: str | None = None # "any", "cheap", "expensive", etc. or None + gap_count: int = 0 # Number of allowed consecutive deviating intervals class PeriodData(NamedTuple): @@ -87,6 +90,16 @@ class ThresholdConfig(NamedTuple): reverse_sort: bool +class IntervalCriteria(NamedTuple): + """Criteria for checking if an interval qualifies for a period.""" + + ref_price: float + avg_price: float + flex: float + min_distance_from_avg: float + reverse_sort: bool + + def calculate_periods( all_prices: list[dict], *, @@ -160,7 +173,13 @@ def calculate_periods( "flex": flex, "min_distance_from_avg": min_distance_from_avg, } - raw_periods = _build_periods(all_prices_sorted, price_context, reverse_sort=reverse_sort) + raw_periods = _build_periods( + all_prices_sorted, + price_context, + reverse_sort=reverse_sort, + level_filter=config.level_filter, + gap_count=config.gap_count, + ) # Step 4: Filter by minimum length raw_periods = _filter_periods_by_min_length(raw_periods, min_period_length) @@ -238,11 +257,123 @@ def _calculate_reference_prices(intervals_by_day: dict[date, list[dict]], *, rev return ref_prices +def _check_level_with_gap_tolerance( + interval_level: int, + level_order: int, + consecutive_gaps: int, + gap_count: int, + *, + reverse_sort: bool, +) -> tuple[bool, bool, int]: + """ + Check if interval meets level requirement with gap tolerance. + + Args: + interval_level: Level value of current interval (from PRICE_LEVEL_MAPPING) + level_order: Required level value + consecutive_gaps: Current count of consecutive gap intervals + gap_count: Maximum allowed consecutive gap intervals + reverse_sort: True for peak price, False for best price + + Returns: + Tuple of (meets_level, is_gap, new_consecutive_gaps): + - meets_level: True if interval qualifies (exact match or within gap tolerance) + - is_gap: True if this is a gap interval (deviates by exactly 1 step) + - new_consecutive_gaps: Updated gap counter + + """ + if reverse_sort: + # Peak price: interval must be >= level_order (e.g., EXPENSIVE or higher) + meets_level_exact = interval_level >= level_order + # Gap: exactly 1 step below (e.g., NORMAL when expecting EXPENSIVE) + is_gap = interval_level == level_order - 1 + else: + # Best price: interval must be <= level_order (e.g., CHEAP or lower) + meets_level_exact = interval_level <= level_order + # Gap: exactly 1 step above (e.g., NORMAL when expecting CHEAP) + is_gap = interval_level == level_order + 1 + + # Apply gap tolerance + if meets_level_exact: + return True, False, 0 # Meets level, not a gap, reset counter + if is_gap and consecutive_gaps < gap_count: + return True, True, consecutive_gaps + 1 # Allowed gap, increment counter + return False, False, 0 # Doesn't meet level, reset counter + + +def _apply_level_filter( + price_data: dict, + level_order: int | None, + consecutive_gaps: int, + gap_count: int, + *, + reverse_sort: bool, +) -> tuple[bool, int]: + """ + Apply level filter to a single interval. + + Args: + price_data: Price data dict with "level" key + level_order: Required level value (from PRICE_LEVEL_MAPPING) or None if disabled + consecutive_gaps: Current count of consecutive gap intervals + gap_count: Maximum allowed consecutive gap intervals + reverse_sort: True for peak price, False for best price + + Returns: + Tuple of (meets_level, new_consecutive_gaps) + + """ + if level_order is None: + return True, consecutive_gaps + + interval_level = PRICE_LEVEL_MAPPING.get(price_data.get("level", "NORMAL"), 0) + meets_level, _is_gap, new_consecutive_gaps = _check_level_with_gap_tolerance( + interval_level, level_order, consecutive_gaps, gap_count, reverse_sort=reverse_sort + ) + return meets_level, new_consecutive_gaps + + +def _check_interval_criteria( + price: float, + criteria: IntervalCriteria, +) -> tuple[bool, bool]: + """ + Check if interval meets flex and minimum distance criteria. + + Args: + price: Interval price + criteria: Interval criteria (ref_price, avg_price, flex, etc.) + + Returns: + Tuple of (in_flex, meets_min_distance) + + """ + # Calculate percentage difference from reference + percent_diff = ((price - criteria.ref_price) / criteria.ref_price) * 100 if criteria.ref_price != 0 else 0.0 + + # Check if interval qualifies for the period + in_flex = percent_diff >= criteria.flex * 100 if criteria.reverse_sort else percent_diff <= criteria.flex * 100 + + # Minimum distance from average + if criteria.reverse_sort: + # Peak price: must be at least min_distance_from_avg% above average + min_distance_threshold = criteria.avg_price * (1 + criteria.min_distance_from_avg / 100) + meets_min_distance = price >= min_distance_threshold + else: + # Best price: must be at least min_distance_from_avg% below average + min_distance_threshold = criteria.avg_price * (1 - criteria.min_distance_from_avg / 100) + meets_min_distance = price <= min_distance_threshold + + return in_flex, meets_min_distance + + def _build_periods( all_prices: list[dict], price_context: dict[str, Any], *, reverse_sort: bool, + level_filter: str | None = None, + gap_count: int = 0, ) -> list[list[dict]]: """ Build periods, allowing periods to cross midnight (day boundary). @@ -251,15 +382,45 @@ def _build_periods( When a day boundary is crossed, the current period is ended. Adjacent periods at midnight are merged in a later step. + Args: + all_prices: All price data points + price_context: Dict with ref_prices, avg_prices, flex, min_distance_from_avg + reverse_sort: True for peak price (high prices), False for best price (low prices) + level_filter: Level filter string ("cheap", "expensive", "any", None) + gap_count: Number of allowed consecutive intervals deviating by exactly 1 level step + """ ref_prices = price_context["ref_prices"] avg_prices = price_context["avg_prices"] flex = price_context["flex"] min_distance_from_avg = price_context["min_distance_from_avg"] + # Calculate level_order if level_filter is active + level_order = None + level_filter_active = False + if level_filter and level_filter.lower() != "any": + level_order = PRICE_LEVEL_MAPPING.get(level_filter.upper(), 0) + level_filter_active = True + filter_direction = "≥" if reverse_sort else "≤" + gap_info = f", gap_tolerance={gap_count}" if gap_count > 0 else "" + _LOGGER.debug( + "%sLevel filter active: %s (order %s, require interval level %s filter level%s)", + INDENT_L3, + level_filter.upper(), + level_order, + filter_direction, + gap_info, + ) + else: + status = "RELAXED to ANY" if (level_filter and level_filter.lower() == "any") else "DISABLED (not configured)" + _LOGGER.debug("%sLevel filter: %s (accepting all levels)", INDENT_L3, status) + periods: list[list[dict]] = [] current_period: list[dict] = [] last_ref_date: date | None = None + consecutive_gaps = 0 # Track consecutive intervals that deviate by 1 level step + intervals_checked = 0 + intervals_filtered_by_level = 0 for price_data in all_prices: starts_at = dt_util.parse_datetime(price_data["startsAt"]) @@ -267,36 +428,37 @@ def _build_periods( continue starts_at = dt_util.as_local(starts_at) date_key = starts_at.date() - ref_price = ref_prices[date_key] - avg_price = avg_prices[date_key] price = float(price_data["total"]) - # Calculate percentage difference from reference - percent_diff = ((price - ref_price) / ref_price) * 100 if ref_price != 0 else 0.0 - percent_diff = round(percent_diff, 2) + intervals_checked += 1 - # Check if interval qualifies for the period - in_flex = percent_diff >= flex * 100 if reverse_sort else percent_diff <= flex * 100 + # Check flex and minimum distance criteria + criteria = IntervalCriteria( + ref_price=ref_prices[date_key], + avg_price=avg_prices[date_key], + flex=flex, + min_distance_from_avg=min_distance_from_avg, + reverse_sort=reverse_sort, + ) + in_flex, meets_min_distance = _check_interval_criteria(price, criteria) - # Minimum distance from average - if reverse_sort: - # Peak price: must be at least min_distance_from_avg% above average - min_distance_threshold = avg_price * (1 + min_distance_from_avg / 100) - meets_min_distance = price >= min_distance_threshold - else: - # Best price: must be at least min_distance_from_avg% below average - min_distance_threshold = avg_price * (1 - min_distance_from_avg / 100) - meets_min_distance = price <= min_distance_threshold + # Level filter: Check if interval meets level requirement with gap tolerance + meets_level, consecutive_gaps = _apply_level_filter( + price_data, level_order, consecutive_gaps, gap_count, reverse_sort=reverse_sort + ) + if not meets_level: + intervals_filtered_by_level += 1 # Split period if day changes if last_ref_date is not None and date_key != last_ref_date and current_period: periods.append(current_period) current_period = [] + consecutive_gaps = 0 # Reset gap counter on day boundary last_ref_date = date_key # Add to period if all criteria are met - if in_flex and meets_min_distance: + if in_flex and meets_min_distance and meets_level: current_period.append( { "interval_hour": starts_at.hour, @@ -310,11 +472,23 @@ def _build_periods( # Criteria no longer met, end current period periods.append(current_period) current_period = [] + consecutive_gaps = 0 # Reset gap counter # Add final period if exists if current_period: periods.append(current_period) + # Log summary + if level_filter_active and intervals_checked > 0: + filtered_pct = (intervals_filtered_by_level / intervals_checked) * 100 + _LOGGER.debug( + "%sLevel filter summary: %d/%d intervals filtered (%.1f%%)", + INDENT_L3, + intervals_filtered_by_level, + intervals_checked, + filtered_pct, + ) + return periods @@ -1196,12 +1370,29 @@ def _relax_single_day( # noqa: PLR0913 - Comprehensive filter relaxation per da continue # Calculate periods with this flex + filter combination - relaxed_config = config._replace(flex=new_flex) + # Apply level override if specified + level_filter_value = lvl_override if lvl_override else config.level_filter + + # Log filter changes + flex_pct = round(abs(new_flex) * 100, 1) + if lvl_override: + _LOGGER.debug( + "%sDay %s flex=%.1f%%: OVERRIDING level_filter: %s → %s", + INDENT_L2, + day_label, + flex_pct, + config.level_filter or "None", + lvl_override.upper(), + ) + + relaxed_config = config._replace( + flex=new_flex, + level_filter=level_filter_value, + ) relaxed_result = calculate_periods(day_prices, config=relaxed_config) new_periods = relaxed_result["periods"] # Build relaxation level label BEFORE marking periods - flex_pct = round(abs(new_flex) * 100, 1) relaxation_level = f"price_diff_{flex_pct}%{label_suffix}" phases_used.append(relaxation_level) diff --git a/custom_components/tibber_prices/translations/de.json b/custom_components/tibber_prices/translations/de.json index 2b21361..0da6150 100644 --- a/custom_components/tibber_prices/translations/de.json +++ b/custom_components/tibber_prices/translations/de.json @@ -104,7 +104,6 @@ "best_price_min_period_length": "Minimale Periodenlänge", "best_price_flex": "Flexibilität: Maximal über dem Mindestpreis", "best_price_min_distance_from_avg": "Mindestabstand: Erforderlich unter dem Tagesdurchschnitt", - "best_price_min_volatility": "Mindest-Volatilitätsfilter", "best_price_max_level": "Preisniveau-Filter (Optional)", "best_price_max_level_gap_count": "Lückentoleranz für Niveaufilter", "enable_min_periods_best": "Mindestanzahl Perioden anstreben", @@ -112,7 +111,6 @@ "relaxation_step_best": "Lockerungsschritt" }, "data_description": { - "best_price_min_volatility": "Zeigt Bestpreis-Perioden nur an, wenn die interne Preisvolatilität der Periode (Preisspanne innerhalb der Periode) mindestens diesem Level entspricht. Standard: 'Niedrig' (zeigt Perioden mit beliebigem Volatilitätslevel) - ermöglicht das Finden günstiger Perioden auch wenn die Preise stabil sind. Wähle 'Moderat'/'Hoch' um nur Perioden mit signifikanten Preisschwankungen anzuzeigen, was auf dynamischere Preismöglichkeiten hinweisen kann.", "best_price_max_level": "Zeigt Bestpreis-Perioden nur an, wenn sie Intervalle mit Preisniveaus ≤ dem gewählten Wert enthalten. Beispiel: Wahl von 'Günstig' bedeutet, dass die Periode mindestens ein 'SEHR_GÜNSTIG' oder 'GÜNSTIG' Intervall haben muss. Dies stellt sicher, dass 'Bestpreis'-Perioden nicht nur relativ günstig für den Tag sind, sondern tatsächlich günstig in absoluten Zahlen. Wähle 'Beliebig' um Bestpreise unabhängig vom absoluten Preisniveau anzuzeigen.", "best_price_max_level_gap_count": "Maximale Anzahl aufeinanderfolgender Intervalle, die exakt um eine Niveaustufe vom geforderten Level abweichen dürfen. Beispiel: Bei Filter 'Günstig' und Lückentoleranz 1 wird die Sequenz 'GÜNSTIG, GÜNSTIG, NORMAL, GÜNSTIG' akzeptiert (NORMAL ist eine Stufe über GÜNSTIG). Dies verhindert, dass Perioden durch gelegentliche Niveau-Abweichungen aufgespalten werden. Standard: 0 (strenge Filterung, keine Toleranz).", "enable_min_periods_best": "Wenn aktiviert, werden Filter schrittweise gelockert, falls nicht genug Perioden gefunden wurden. Dies versucht die gewünschte Mindestanzahl zu erreichen, was dazu führen kann, dass auch weniger optimale Zeiträume als Bestpreis-Perioden markiert werden.", @@ -128,7 +126,6 @@ "peak_price_min_period_length": "Minimale Periodenlänge", "peak_price_flex": "Flexibilität: Maximal unter dem Höchstpreis (negativer Wert)", "peak_price_min_distance_from_avg": "Mindestabstand: Erforderlich über dem Tagesdurchschnitt", - "peak_price_min_volatility": "Mindest-Volatilitätsfilter", "peak_price_min_level": "Preisniveau-Filter (Optional)", "peak_price_max_level_gap_count": "Lückentoleranz für Niveaufilter", "enable_min_periods_peak": "Mindestanzahl Perioden anstreben", @@ -136,7 +133,6 @@ "relaxation_step_peak": "Lockerungsschritt" }, "data_description": { - "peak_price_min_volatility": "Zeigt Spitzenpreis-Perioden nur an, wenn die interne Preisvolatilität der Periode (Preisspanne innerhalb der Periode) mindestens diesem Level entspricht. Standard: 'Niedrig' (zeigt Perioden mit beliebigem Volatilitätslevel) - ermöglicht das Identifizieren teurer Perioden auch wenn die Preise stabil sind. Wähle 'Moderat'/'Hoch' um nur Perioden mit signifikanten Preisschwankungen anzuzeigen, was auf dringenderen Bedarf hinweisen kann, diese Zeiten zu vermeiden.", "peak_price_min_level": "Zeigt Spitzenpreis-Perioden nur an, wenn sie Intervalle mit Preisniveaus ≥ dem gewählten Wert enthalten. Beispiel: Wahl von 'Teuer' bedeutet, dass die Periode mindestens ein 'TEUER' oder 'SEHR_TEUER' Intervall haben muss. Dies stellt sicher, dass 'Spitzenpreis'-Perioden nicht nur relativ teuer für den Tag sind, sondern tatsächlich teuer in absoluten Zahlen. Wähle 'Beliebig' um Spitzenpreise unabhängig vom absoluten Preisniveau anzuzeigen.", "peak_price_max_level_gap_count": "Maximale Anzahl aufeinanderfolgender Intervalle, die exakt um eine Niveaustufe vom geforderten Level abweichen dürfen. Beispiel: Bei Filter 'Teuer' und Lückentoleranz 2 wird die Sequenz 'TEUER, NORMAL, NORMAL, TEUER' akzeptiert (NORMAL ist eine Stufe unter TEUER). Dies verhindert, dass Perioden durch gelegentliche Niveau-Abweichungen aufgespalten werden. Standard: 0 (strenge Filterung, keine Toleranz).", "enable_min_periods_peak": "Wenn aktiviert, werden Filter schrittweise gelockert, falls nicht genug Perioden gefunden wurden. Dies versucht die gewünschte Mindestanzahl zu erreichen, um sicherzustellen, dass du auch an Tagen mit ungewöhnlichen Preismustern vor teuren Perioden gewarnt wirst.", diff --git a/custom_components/tibber_prices/translations/en.json b/custom_components/tibber_prices/translations/en.json index ad0a6a1..468cb61 100644 --- a/custom_components/tibber_prices/translations/en.json +++ b/custom_components/tibber_prices/translations/en.json @@ -104,7 +104,6 @@ "best_price_min_period_length": "Minimum Period Length", "best_price_flex": "Flexibility: Maximum above minimum price", "best_price_min_distance_from_avg": "Minimum Distance: Required below daily average", - "best_price_min_volatility": "Minimum Volatility Filter", "best_price_max_level": "Price Level Filter (Optional)", "best_price_max_level_gap_count": "Level Filter Gap Tolerance", "enable_min_periods_best": "Try to Achieve Minimum Period Count", @@ -112,7 +111,6 @@ "relaxation_step_best": "Filter Relaxation Step Size" }, "data_description": { - "best_price_min_volatility": "Only show best price periods when the period's internal price volatility (spread within the period) meets or exceeds this level. Default: 'Low' (show periods with any volatility level) - allows finding cheap periods even if prices are stable. Select 'Moderate'/'High' to only show periods with significant price variations, which may indicate more dynamic pricing opportunities.", "best_price_max_level": "Only show best price periods if they contain intervals with price levels ≤ selected value. For example, selecting 'Cheap' means the period must have at least one 'VERY_CHEAP' or 'CHEAP' interval. This ensures 'best price' periods are not just relatively cheap for the day, but actually cheap in absolute terms. Select 'Any' to show best prices regardless of their absolute price level.", "best_price_max_level_gap_count": "Maximum number of consecutive intervals allowed that deviate by exactly one level step from the required level. For example: with 'Cheap' filter and gap count 1, a sequence 'CHEAP, CHEAP, NORMAL, CHEAP' is accepted (NORMAL is one step above CHEAP). This prevents periods from being split by occasional level deviations. Default: 0 (strict filtering, no tolerance).", "enable_min_periods_best": "When enabled, filters will be gradually relaxed if not enough periods are found. This attempts to reach the desired minimum number of periods, which may include less optimal time windows as best-price periods.", @@ -128,7 +126,6 @@ "peak_price_min_period_length": "Minimum Period Length", "peak_price_flex": "Flexibility: Maximum below maximum price (negative value)", "peak_price_min_distance_from_avg": "Minimum Distance: Required above daily average", - "peak_price_min_volatility": "Minimum Volatility Filter", "peak_price_min_level": "Price Level Filter (Optional)", "peak_price_max_level_gap_count": "Level Filter Gap Tolerance", "enable_min_periods_peak": "Try to Achieve Minimum Period Count", @@ -136,7 +133,6 @@ "relaxation_step_peak": "Filter Relaxation Step Size" }, "data_description": { - "peak_price_min_volatility": "Only show peak price periods when the period's internal price volatility (spread within the period) meets or exceeds this level. Default: 'Low' (show periods with any volatility level) - allows identifying expensive periods even if prices are stable. Select 'Moderate'/'High' to only show periods with significant price variations, which may indicate more urgent need to avoid these times.", "peak_price_min_level": "Only show peak price periods if they contain intervals with price levels ≥ selected value. For example, selecting 'Expensive' means the period must have at least one 'EXPENSIVE' or 'VERY_EXPENSIVE' interval. This ensures 'peak price' periods are not just relatively expensive for the day, but actually expensive in absolute terms. Select 'Any' to show peak prices regardless of their absolute price level.", "peak_price_max_level_gap_count": "Maximum number of consecutive intervals allowed that deviate by exactly one level step from the required level. For example: with 'Expensive' filter and gap count 2, a sequence 'EXPENSIVE, NORMAL, NORMAL, EXPENSIVE' is accepted (NORMAL is one step below EXPENSIVE). This prevents periods from being split by occasional level deviations. Default: 0 (strict filtering, no tolerance).", "enable_min_periods_peak": "When enabled, filters will be gradually relaxed if not enough periods are found. This attempts to reach the desired minimum number of periods to ensure you're warned about expensive periods even on days with unusual price patterns.", diff --git a/custom_components/tibber_prices/translations/nb.json b/custom_components/tibber_prices/translations/nb.json index dd57633..23d7d34 100644 --- a/custom_components/tibber_prices/translations/nb.json +++ b/custom_components/tibber_prices/translations/nb.json @@ -104,7 +104,6 @@ "best_price_min_period_length": "Minimum periodelengde", "best_price_flex": "Fleksibilitet: Maksimum % over minimumspris", "best_price_min_distance_from_avg": "Minimumsavstand: Påkrevd % under daglig gjennomsnitt", - "best_price_min_volatility": "Minimum volatilitetsfilter", "best_price_max_level": "Prisnivåfilter (valgfritt)", "best_price_max_level_gap_count": "Gaptoleranse for nivåfilter", "enable_min_periods_best": "Prøv å oppnå minimum antall perioder", @@ -112,7 +111,6 @@ "relaxation_step_best": "Avslappingstrinn" }, "data_description": { - "best_price_min_volatility": "Vis kun beste prisperioder når periodens interne prisvolatilitet (prisspennet innenfor perioden) oppfyller eller overskrider dette nivået. Standard: 'Lav' (vis perioder med hvilket som helst volatilitetsnivå) - gjør det mulig å finne billige perioder selv om prisene er stabile. Velg 'Moderat'/'Høy' for kun å vise perioder med betydelige prisvariasjoner, noe som kan indikere mer dynamiske prismuligheter.", "best_price_max_level": "Vis kun beste prisperioder hvis de inneholder intervaller med prisnivåer ≤ valgt verdi. For eksempel: å velge 'Billig' betyr at perioden må ha minst ett 'VELDIG_BILLIG' eller 'BILLIG' intervall. Dette sikrer at 'beste pris'-perioder ikke bare er relativt billige for dagen, men faktisk billige i absolutte tall. Velg 'Alle' for å vise beste priser uavhengig av deres absolutte prisnivå.", "enable_min_periods_best": "Når aktivert vil filtrene gradvis bli lempeligere hvis det ikke blir funnet nok perioder. Dette forsøker å nå ønsket minimum antall perioder, noe som kan føre til at mindre optimale tidsrom blir markert som beste-pris-perioder.", "min_periods_best": "Minimum antall beste-pris-perioder å sikte mot per dag. Filtre vil bli lempet trinn for trinn for å prøve å oppnå dette antallet. Kun aktiv når 'Prøv å oppnå minimum antall perioder' er aktivert. Standard: 1", @@ -128,7 +126,6 @@ "peak_price_min_period_length": "Minimum periodelengde", "peak_price_flex": "Fleksibilitet: Maksimum % under maksimumspris (negativ verdi)", "peak_price_min_distance_from_avg": "Minimumsavstand: Påkrevd % over daglig gjennomsnitt", - "peak_price_min_volatility": "Minimum volatilitetsfilter", "peak_price_min_level": "Prisnivåfilter (valgfritt)", "peak_price_max_level_gap_count": "Gaptoleranse for nivåfilter", "enable_min_periods_peak": "Prøv å oppnå minimum antall perioder", @@ -136,7 +133,6 @@ "relaxation_step_peak": "Avslappingstrinn" }, "data_description": { - "peak_price_min_volatility": "Vis kun topprisperioder når periodens interne prisvolatilitet (prisspennet innenfor perioden) oppfyller eller overskrider dette nivået. Standard: 'Lav' (vis perioder med hvilket som helst volatilitetsnivå) - gjør det mulig å identifisere dyre perioder selv om prisene er stabile. Velg 'Moderat'/'Høy' for kun å vise perioder med betydelige prisvariasjoner, noe som kan indikere mer presserende behov for å unngå disse tidspunktene.", "peak_price_min_level": "Vis kun topprisperioder hvis de inneholder intervaller med prisnivåer ≥ valgt verdi. For eksempel: å velge 'Dyr' betyr at perioden må ha minst ett 'DYR' eller 'VELDIG_DYR' intervall. Dette sikrer at 'topppris'-perioder ikke bare er relativt dyre for dagen, men faktisk dyre i absolutte tall. Velg 'Alle' for å vise topppriser uavhengig av deres absolutte prisnivå.", "enable_min_periods_peak": "Når aktivert vil filtrene gradvis bli lempeligere hvis det ikke blir funnet nok perioder. Dette forsøker å nå ønsket minimum antall perioder for å sikre at du blir advart om dyre perioder selv på dager med uvanlige prismønstre.", "min_periods_peak": "Minimum antall topp-pris-perioder å sikte mot per dag. Filtre vil bli lempet trinn for trinn for å prøve å oppnå dette antallet. Kun aktiv når 'Prøv å oppnå minimum antall perioder' er aktivert. Standard: 1", @@ -515,4 +511,4 @@ } }, "title": "Tibber Prisinformasjon & Vurderinger" -} \ No newline at end of file +} diff --git a/custom_components/tibber_prices/translations/nl.json b/custom_components/tibber_prices/translations/nl.json index 71117d8..4ac94e0 100644 --- a/custom_components/tibber_prices/translations/nl.json +++ b/custom_components/tibber_prices/translations/nl.json @@ -104,7 +104,6 @@ "best_price_min_period_length": "Minimale periode lengte", "best_price_flex": "Flexibiliteit: Maximaal % boven minimumprijs", "best_price_min_distance_from_avg": "Minimale afstand: Vereist % onder dagelijks gemiddelde", - "best_price_min_volatility": "Minimum volatiliteitsfilter", "best_price_max_level": "Prijsniveaufilter (Optioneel)", "best_price_max_level_gap_count": "Gaptolerantie voor niveaufilter", "enable_min_periods_best": "Probeer minimum aantal periodes te bereiken", @@ -112,7 +111,6 @@ "relaxation_step_best": "Ontspanningsstap" }, "data_description": { - "best_price_min_volatility": "Toon alleen beste prijsperiodes wanneer de interne prijsvolatiliteit van de periode (prijsspanne binnen de periode) dit niveau bereikt of overschrijdt. Standaard: 'Laag' (toon periodes met elk volatiliteitsniveau) - maakt het mogelijk om goedkope periodes te vinden zelfs als de prijzen stabiel zijn. Selecteer 'Matig'/'Hoog' om alleen periodes met significante prijsvariaties te tonen, wat kan wijzen op meer dynamische prijsmogelijkheden.", "best_price_max_level": "Toon alleen beste prijsperiodes als ze intervallen bevatten met prijsniveaus ≤ geselecteerde waarde. Bijvoorbeeld: selecteren van 'Goedkoop' betekent dat de periode minstens één 'ZEER_GOEDKOOP' of 'GOEDKOOP' interval moet hebben. Dit zorgt ervoor dat 'beste prijs'-periodes niet alleen relatief goedkoop zijn voor de dag, maar daadwerkelijk goedkoop in absolute termen. Selecteer 'Alle' om beste prijzen te tonen ongeacht hun absolute prijsniveau.", "enable_min_periods_best": "Wanneer ingeschakeld worden filters geleidelijk versoepeld als er niet genoeg periodes worden gevonden. Dit probeert het gewenste minimum aantal periodes te bereiken om ervoor te zorgen dat je kansen hebt om van lage prijzen te profiteren, zelfs op dagen met ongebruikelijke prijspatronen.", "min_periods_best": "Minimum aantal beste prijsperiodes om naar te streven per dag. Filters worden stap voor stap versoepeld om dit aantal te proberen bereiken. Alleen actief wanneer 'Probeer minimum aantal periodes te bereiken' is ingeschakeld. Standaard: 1", @@ -128,7 +126,6 @@ "peak_price_min_period_length": "Minimale periode lengte", "peak_price_flex": "Flexibiliteit: Maximaal % onder maximumprijs (negatieve waarde)", "peak_price_min_distance_from_avg": "Minimale afstand: Vereist % boven dagelijks gemiddelde", - "peak_price_min_volatility": "Minimum volatiliteitsfilter", "peak_price_min_level": "Prijsniveaufilter (Optioneel)", "peak_price_max_level_gap_count": "Gaptolerantie voor niveaufilter", "enable_min_periods_peak": "Probeer minimum aantal periodes te bereiken", @@ -136,7 +133,6 @@ "relaxation_step_peak": "Ontspanningsstap" }, "data_description": { - "peak_price_min_volatility": "Toon alleen piekprijsperiodes wanneer de interne prijsvolatiliteit van de periode (prijsspanne binnen de periode) dit niveau bereikt of overschrijdt. Standaard: 'Laag' (toon periodes met elk volatiliteitsniveau) - maakt het mogelijk om dure periodes te identificeren zelfs als de prijzen stabiel zijn. Selecteer 'Matig'/'Hoog' om alleen periodes met significante prijsvariaties te tonen, wat kan wijzen op een urgenter noodzaak om deze tijden te vermijden.", "peak_price_min_level": "Toon alleen piekprijsperiodes als ze intervallen bevatten met prijsniveaus ≥ geselecteerde waarde. Bijvoorbeeld: selecteren van 'Duur' betekent dat de periode minstens één 'DUUR' of 'ZEER_DUUR' interval moet hebben. Dit zorgt ervoor dat 'piekprijs'-periodes niet alleen relatief duur zijn voor de dag, maar daadwerkelijk duur in absolute termen. Selecteer 'Alle' om piekprijzen te tonen ongeacht hun absolute prijsniveau.", "enable_min_periods_peak": "Wanneer ingeschakeld worden filters geleidelijk versoepeld als er niet genoeg periodes worden gevonden. Dit probeert het gewenste minimum aantal periodes te bereiken om ervoor te zorgen dat je wordt gewaarschuwd voor dure periodes, zelfs op dagen met ongebruikelijke prijspatronen.", "min_periods_peak": "Minimum aantal piekprijsperiodes om naar te streven per dag. Filters worden stap voor stap versoepeld om dit aantal te proberen bereiken. Alleen actief wanneer 'Probeer minimum aantal periodes te bereiken' is ingeschakeld. Standaard: 1", @@ -515,4 +511,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 298bb2b..7f24197 100644 --- a/custom_components/tibber_prices/translations/sv.json +++ b/custom_components/tibber_prices/translations/sv.json @@ -104,7 +104,6 @@ "best_price_min_period_length": "Minsta periodlängd", "best_price_flex": "Flexibilitet: Maximalt % över minimumpris", "best_price_min_distance_from_avg": "Minimiavstånd: Krävd % under dagligt genomsnitt", - "best_price_min_volatility": "Minimum volatilitetsfilter", "best_price_max_level": "Prisnivåfilter (Valfritt)", "best_price_max_level_gap_count": "Gaptolerens för nivåfilter", "enable_min_periods_best": "Försök uppnå minsta antal perioder", @@ -112,7 +111,6 @@ "relaxation_step_best": "Avslappningssteg" }, "data_description": { - "best_price_min_volatility": "Visa endast bästa prisperioder när periodens interna prisvolatilitet (prisspann inom perioden) uppfyller eller överskrider denna nivå. Standard: 'Låg' (visa perioder med valfri volatilitetsnivå) - möjliggör att hitta billiga perioder även om priserna är stabila. Välj 'Måttlig'/'Hög' för att endast visa perioder med betydande prisvariationer, vilket kan indikera mer dynamiska prismöjligheter.", "best_price_max_level": "Visa endast bästa prisperioder om de innehåller intervall med prisnivåer ≤ valt värde. Till exempel: att välja 'Billigt' betyder att perioden måste ha minst ett 'MYCKET_BILLIGT' eller 'BILLIGT' intervall. Detta säkerställer att 'bästa pris'-perioder inte bara är relativt billiga för dagen, utan faktiskt billiga i absoluta tal. Välj 'Alla' för att visa bästa priser oavsett deras absoluta prisnivå.", "enable_min_periods_best": "När aktiverad kommer filtren att gradvis luckras upp om inte tillräckligt många perioder hittas. Detta försöker uppnå det önskade minsta antalet perioder för att säkerställa att du har möjligheter att dra nytta av låga priser även på dagar med ovanliga prismönster.", "min_periods_best": "Minsta antal bästa prisperioder att sträva efter per dag. Filtren kommer att luckras upp steg för steg för att försöka uppnå detta antal. Endast aktiv när 'Försök uppnå minsta antal perioder' är aktiverad. Standard: 1", @@ -128,7 +126,6 @@ "peak_price_min_period_length": "Minsta periodlängd", "peak_price_flex": "Flexibilitet: Maximalt % under maximumpris (negativt värde)", "peak_price_min_distance_from_avg": "Minimiavstånd: Krävd % över dagligt genomsnitt", - "peak_price_min_volatility": "Minimum volatilitetsfilter", "peak_price_min_level": "Prisnivåfilter (Valfritt)", "peak_price_max_level_gap_count": "Gaptolerens för nivåfilter", "enable_min_periods_peak": "Försök uppnå minsta antal perioder", @@ -136,7 +133,6 @@ "relaxation_step_peak": "Avslappningssteg" }, "data_description": { - "peak_price_min_volatility": "Visa endast topprisperioder om de har en intern prisvolatilitet (prisspann inom perioden) som uppfyller eller överskrider denna nivå. Standard: 'Låg' (visa oavsett periodens volatilitet) - toppvarningar är relevanta även vid låg intern spridning. Högre volatilitet inom en period kan indikera mer brådskande behov att undvika dessa tider (eftersom priserna varierar kraftigt även inom den korta perioden).", "peak_price_min_level": "Visa endast topprisperioder om de innehåller intervall med prisnivåer ≥ valt värde. Till exempel måste perioden om du väljer 'Dyr' ha minst ett 'DYR' eller 'MYCKET_DYR' intervall. Detta säkerställer att 'toppris'-perioder inte bara är relativt dyra för dagen, utan faktiskt dyra i absoluta termer (inte bara 'lite dyrare än genomsnittet på en billig dag').", "enable_min_periods_peak": "När aktiverad kommer filtren att gradvis luckras upp om inte tillräckligt många perioder hittas. Detta försöker uppnå det önskade minsta antalet perioder för att säkerställa att du blir varnad för dyra perioder även på dagar med ovanliga prismönster.", "min_periods_peak": "Minsta antal topprisperioder att sträva efter per dag. Filtren kommer att luckras upp steg för steg för att försöka uppnå detta antal. Endast aktiv när 'Försök uppnå minsta antal perioder' är aktiverad. Standard: 1", @@ -515,4 +511,4 @@ } }, "title": "Tibber Prisinformation & Betyg" -} \ No newline at end of file +} diff --git a/docs/user/period-calculation.md b/docs/user/period-calculation.md index 27fb8be..8268480 100644 --- a/docs/user/period-calculation.md +++ b/docs/user/period-calculation.md @@ -1,572 +1,1141 @@ -# Period Calculation# Period Calculation +# Period Calculation# Period Calculation# Period Calculation -Learn how Best Price and Peak Price periods work, and how to configure them for your needs.Learn how Best Price and Peak Price periods work, and how to configure them for your needs. +Learn how Best Price and Peak Price periods work, and how to configure them for your needs. -## Table of Contents## Table of Contents +## Table of ContentsLearn how Best Price and Peak Price periods work, and how to configure them for your needs.Learn how Best Price and Peak Price periods work, and how to configure them for your needs. -- [Quick Start](#quick-start)- [Quick Start](#quick-start) +- [Quick Start](#quick-start) -- [How It Works](#how-it-works)- [How It Works](#how-it-works) +- [How It Works](#how-it-works) -- [Configuration Guide](#configuration-guide)- [Configuration Guide](#configuration-guide) +- [Configuration Guide](#configuration-guide)## Table of Contents## Table of Contents -- [Understanding Relaxation](#understanding-relaxation)- [Understanding Relaxation](#understanding-relaxation) +- [Understanding Relaxation](#understanding-relaxation) -- [Common Scenarios](#common-scenarios)- [Common Scenarios](#common-scenarios) +- [Common Scenarios](#common-scenarios) -- [Troubleshooting](#troubleshooting)- [Troubleshooting](#troubleshooting) +- [Troubleshooting](#troubleshooting) + +- [Advanced Topics](#advanced-topics)- [Quick Start](#quick-start)- [Quick Start](#quick-start) + + + +---- [How It Works](#how-it-works)- [How It Works](#how-it-works) + + + +## Quick Start- [Configuration Guide](#configuration-guide)- [Configuration Guide](#configuration-guide) + + + +### What Are Price Periods?- [Understanding Relaxation](#understanding-relaxation)- [Understanding Relaxation](#understanding-relaxation) + + + +The integration finds time windows when electricity is especially **cheap** (Best Price) or **expensive** (Peak Price):- [Common Scenarios](#common-scenarios)- [Common Scenarios](#common-scenarios) + + + +- **Best Price Periods** 🟢 - When to run your dishwasher, charge your EV, or heat water- [Troubleshooting](#troubleshooting)- [Troubleshooting](#troubleshooting) + +- **Peak Price Periods** 🔴 - When to reduce consumption or defer non-essential loads - [Advanced Topics](#advanced-topics)- [Advanced Topics](#advanced-topics) +### Default Behavior + +Out of the box, the integration: + ------ +1. **Best Price**: Finds cheapest 1-hour+ windows that are at least 5% below the daily average +2. **Peak Price**: Finds most expensive 1-hour+ windows that are at least 5% above the daily average + +3. **Relaxation**: Automatically loosens filters if not enough periods are found ## Quick Start## Quick Start - - -### What Are Price Periods?### What Are Price Periods? +### Example Timeline -The integration finds time windows when electricity is especially **cheap** (Best Price) or **expensive** (Peak Price):The integration finds time windows when electricity is especially **cheap** (Best Price) or **expensive** (Peak Price): +``` + +00:00 ████████████████ Best Price Period (cheap prices)### What Are Price Periods?### What Are Price Periods? + +04:00 ░░░░░░░░░░░░░░░░ Normal + +08:00 ████████████████ Peak Price Period (expensive prices) + +12:00 ░░░░░░░░░░░░░░░░ Normal + +16:00 ████████████████ Peak Price Period (expensive prices)The integration finds time windows when electricity is especially **cheap** (Best Price) or **expensive** (Peak Price):The integration finds time windows when electricity is especially **cheap** (Best Price) or **expensive** (Peak Price): + +20:00 ████████████████ Best Price Period (cheap prices) + +``` -- **Best Price Periods** 🟢 - When to run your dishwasher, charge your EV, or heat water- **Best Price Periods** 🟢 - When to run your dishwasher, charge your EV, or heat water - -- **Peak Price Periods** 🔴 - When to reduce consumption or defer non-essential loads- **Peak Price Periods** 🔴 - When to reduce consumption or defer non-essential loads +---- **Best Price Periods** 🟢 - When to run your dishwasher, charge your EV, or heat water- **Best Price Periods** 🟢 - When to run your dishwasher, charge your EV, or heat water -### Default Behavior### Default Behavior +## How It Works- **Peak Price Periods** 🔴 - When to reduce consumption or defer non-essential loads- **Peak Price Periods** 🔴 - When to reduce consumption or defer non-essential loads -Out of the box, the integration:Out of the box, the integration: - -- ✅ Finds the cheapest time windows each day (Best Price)- ✅ Finds the cheapest time windows each day (Best Price) - -- ✅ Finds the most expensive time windows each day (Peak Price)- ✅ Finds the most expensive time windows each day (Peak Price) - -- ✅ Requires periods to be at least 1 hour long- ✅ Requires periods to be at least 1 hour long - -- ✅ Automatically adjusts when no perfect matches exist (Relaxation)- ✅ Automatically adjusts when no perfect matches exist (Relaxation) +### The Four-Step Process -**Most users don't need to change anything!** The defaults work well for typical use cases.**Most users don't need to change anything!** The defaults work well for typical use cases. +#### 1. Find Extreme Prices### Default Behavior### Default Behavior ------- +First, identify the **minimum** (for Best Price) or **maximum** (for Peak Price) price of the day: -## How It Works## How It Works +```Out of the box, the integration:Out of the box, the integration: + +Daily prices: 18, 20, 22, 25, 23, 19, 17, 21 ct + +Minimum: 17 ct → Best Price anchor- ✅ Finds the cheapest time windows each day (Best Price)- ✅ Finds the cheapest time windows each day (Best Price) + +Maximum: 25 ct → Peak Price anchor + +```- ✅ Finds the most expensive time windows each day (Peak Price)- ✅ Finds the most expensive time windows each day (Peak Price) -### The Basic Idea### The Basic Idea +#### 2. Apply Flexibility- ✅ Requires periods to be at least 1 hour long- ✅ Requires periods to be at least 1 hour long -Each day, the integration analyzes all 96 quarter-hourly price intervals and identifies **continuous time ranges** that meet specific criteria.Each day, the integration analyzes all 96 quarter-hourly price intervals and identifies **continuous time ranges** that meet specific criteria. +Include intervals within a tolerance range of the extreme:- ✅ Automatically adjusts when no perfect matches exist (Relaxation)- ✅ Automatically adjusts when no perfect matches exist (Relaxation) -Think of it like this:Think of it like this: +**Best Price:** + +- Default: +15% above minimum + +- Example: 17 ct × 1.15 = 19.55 ct**Most users don't need to change anything!** The defaults work well for typical use cases.**Most users don't need to change anything!** The defaults work well for typical use cases. + +- Qualifying intervals: 17, 18, 19 ct ✓ + + + +**Peak Price:** + +- Default: -15% below maximum (flex = -15%)------ + +- Example: 25 ct × 0.85 = 21.25 ct + +- Qualifying intervals: 25, 23, 22 ct ✓ + + + +#### 3. Apply Quality Filter (Min Distance)## How It Works## How It Works + + + +Ensure periods are **significantly** different from the daily average: + + + +```### The Basic Idea### The Basic Idea + +Daily average: 21 ct + +Minimum distance: 5% (default) + + + +Best Price threshold: 21 ct × 0.95 = 19.95 ctEach day, the integration analyzes all 96 quarter-hourly price intervals and identifies **continuous time ranges** that meet specific criteria.Each day, the integration analyzes all 96 quarter-hourly price intervals and identifies **continuous time ranges** that meet specific criteria. + +→ Periods must average below 19.95 ct + + + +Peak Price threshold: 21 ct × 1.05 = 22.05 ct + +→ Periods must average above 22.05 ctThink of it like this:Think of it like this: + +``` 1. **Find potential windows** - Times close to the daily MIN (Best Price) or MAX (Peak Price)1. **Find potential windows** - Times close to the daily MIN (Best Price) or MAX (Peak Price) +#### 4. Apply Optional Filters + 2. **Filter by quality** - Ensure they're meaningfully different from average2. **Filter by quality** - Ensure they're meaningfully different from average -3. **Check duration** - Must be long enough to be useful3. **Check duration** - Must be long enough to be useful +You can optionally require: -4. **Apply preferences** - Optional: only show stable prices, avoid mediocre times4. **Apply preferences** - Optional: only show stable prices, avoid mediocre times +- **Absolute quality** (level filter) - "Only show if prices are CHEAP/EXPENSIVE (not just below/above average)"3. **Check duration** - Must be long enough to be useful3. **Check duration** - Must be long enough to be useful -### Step-by-Step Process### Step-by-Step Process +### Visual Example4. **Apply preferences** - Optional: only show stable prices, avoid mediocre times4. **Apply preferences** - Optional: only show stable prices, avoid mediocre times -#### 1. Define the Search Range (Flexibility)#### 1. Define the Search Range (Flexibility) +``` + +Prices (ct/kWh): + +00:00 17 ████████████████ ← Minimum (anchor)### Step-by-Step Process### Step-by-Step Process + +01:00 18 █████████████████ + +02:00 19 ██████████████████ + +03:00 20 ███████████████████ ← Flexibility limit (17 × 1.15 = 19.55) + +04:00 22 █████████████████████#### 1. Define the Search Range (Flexibility)#### 1. Define the Search Range (Flexibility) + +05:00 25 ████████████████████████ ← Maximum + +06:00 21 ████████████████████ ← Daily average -**Best Price:** How much MORE than the daily minimum can a price be?**Best Price:** How much MORE than the daily minimum can a price be? +Best Price Period: 00:00-03:00 (17-19 ct, avg 18 ct)**Best Price:** How much MORE than the daily minimum can a price be?**Best Price:** How much MORE than the daily minimum can a price be? + +✓ Within flex (≤19.55 ct) + +✓ Below quality threshold (18 ct < 19.95 ct)`````` + +✓ Long enough (3 hours ≥ 1 hour) + +```Daily MIN: 20 ct/kWhDaily MIN: 20 ct/kWh + + + +---Flexibility: 15% (default)Flexibility: 15% (default) + + + +## Configuration Guide→ Search for times ≤ 23 ct/kWh (20 + 15%)→ Search for times ≤ 23 ct/kWh (20 + 15%) + + + +### Core Parameters`````` + + + +#### Minimum Period Length + + + +**What:** Shortest acceptable period duration**Peak Price:** How much LESS than the daily maximum can a price be?**Peak Price:** How much LESS than the daily maximum can a price be? + +**Default:** 60 minutes + +**Range:** 15-480 minutes`````` + + + +```yamlDaily MAX: 40 ct/kWhDaily MAX: 40 ct/kWh + +best_price_min_period_length: 60 # At least 1 hour + +```Flexibility: -15% (default)Flexibility: -15% (default) + + + +**Use case:** Match appliance run times (dishwasher ECO: 180 min, quick wash: 60 min)→ Search for times ≥ 34 ct/kWh (40 - 15%)→ Search for times ≥ 34 ct/kWh (40 - 15%) + + + +#### Flexibility`````` + + + +**What:** How far from the extreme price to search + +**Default:** 15% (Best Price), -15% (Peak Price) + +**Range:** 0-30%**Why flexibility?** Prices rarely stay at exactly MIN/MAX. Flexibility lets you capture realistic time windows.**Why flexibility?** Prices rarely stay at exactly MIN/MAX. Flexibility lets you capture realistic time windows. + + + +```yaml + +best_price_flex: 15 # Up to 15% above minimum + +peak_price_flex: -15 # Up to 15% below maximum#### 2. Ensure Quality (Distance from Average)#### 2. Ensure Quality (Distance from Average) + +``` + + + +**Use case:** + +- **Tight** (5-10%): Only show truly extreme periodsPeriods must be meaningfully different from the daily average:Periods must be meaningfully different from the daily average: + +- **Relaxed** (20-30%): Show more opportunities + + + +#### Minimum Distance from Average `````` -Daily MIN: 20 ct/kWhDaily MIN: 20 ct/kWh +**What:** How much better than average a period must be -Flexibility: 15% (default)Flexibility: 15% (default) +**Default:** 5%Daily AVG: 30 ct/kWhDaily AVG: 30 ct/kWh -→ Search for times ≤ 23 ct/kWh (20 + 15%)→ Search for times ≤ 23 ct/kWh (20 + 15%) - -`````` - - - -**Peak Price:** How much LESS than the daily maximum can a price be?**Peak Price:** How much LESS than the daily maximum can a price be? - -`````` - -Daily MAX: 40 ct/kWhDaily MAX: 40 ct/kWh - -Flexibility: -15% (default)Flexibility: -15% (default) - -→ Search for times ≥ 34 ct/kWh (40 - 15%)→ Search for times ≥ 34 ct/kWh (40 - 15%) - -`````` - - - -**Why flexibility?** Prices rarely stay at exactly MIN/MAX. Flexibility lets you capture realistic time windows.**Why flexibility?** Prices rarely stay at exactly MIN/MAX. Flexibility lets you capture realistic time windows. - - - -#### 2. Ensure Quality (Distance from Average)#### 2. Ensure Quality (Distance from Average) - - - -Periods must be meaningfully different from the daily average:Periods must be meaningfully different from the daily average: - - - -`````` - -Daily AVG: 30 ct/kWhDaily AVG: 30 ct/kWh +**Range:** 0-20% Minimum distance: 2% (default)Minimum distance: 2% (default) +```yaml + +best_price_min_distance_from_avg: 5 # Must be 5% below average + +peak_price_min_distance_from_avg: 5 # Must be 5% above average + +```Best Price: Must be ≤ 29.4 ct/kWh (30 - 2%)Best Price: Must be ≤ 29.4 ct/kWh (30 - 2%) -Best Price: Must be ≤ 29.4 ct/kWh (30 - 2%)Best Price: Must be ≤ 29.4 ct/kWh (30 - 2%) -Peak Price: Must be ≥ 30.6 ct/kWh (30 + 2%)Peak Price: Must be ≥ 30.6 ct/kWh (30 + 2%) +**Use case:**Peak Price: Must be ≥ 30.6 ct/kWh (30 + 2%)Peak Price: Must be ≥ 30.6 ct/kWh (30 + 2%) + +- **Strict** (5-10%): Only show significant opportunities + +- **Relaxed** (1-3%): Show more periods, even marginal ones`````` + + + +### Optional Filters + + + +#### Level Filter (Absolute Quality)**Why?** This prevents marking mediocre times as "best" just because they're slightly below average.**Why?** This prevents marking mediocre times as "best" just because they're slightly below average. + + + +**What:** Only show periods with CHEAP/EXPENSIVE intervals (not just below/above average) + +**Default:** `any` (disabled) + +**Options:** `any` | `cheap` | `very_cheap` (Best Price) | `expensive` | `very_expensive` (Peak Price)#### 3. Check Duration#### 3. Check Duration + + + +```yaml + +best_price_max_level: any # Show any period below average + +best_price_max_level: cheap # Only show if at least one interval is CHEAPPeriods must be long enough to be practical:Periods must be long enough to be practical: + +``` `````` - - -**Why?** This prevents marking mediocre times as "best" just because they're slightly below average.**Why?** This prevents marking mediocre times as "best" just because they're slightly below average. - - - -#### 3. Check Duration#### 3. Check Duration - - - -Periods must be long enough to be practical:Periods must be long enough to be practical: - -`````` +**Use case:** "Only notify me when prices are objectively cheap/expensive" Default: 60 minutes minimumDefault: 60 minutes minimum +#### Gap Tolerance (for Level Filter) -45-minute period → Discarded45-minute period → Discarded + +**What:** Allow N consecutive intervals that deviate by exactly one level step + +**Default:** 0 (strict filtering)45-minute period → Discarded45-minute period → Discarded + +**Range:** 0-5 90-minute period → Kept ✓90-minute period → Kept ✓ -`````` +```yaml + +best_price_max_level_gap_count: 1 # Allow 1 NORMAL interval between CHEAP ones`````` + +``` +**Use case:** Prevent periods from being split by occasional level deviations + #### 4. Apply Optional Filters#### 4. Apply Optional Filters +### Relaxation Settings + +#### Enable Relaxation + You can optionally require:You can optionally require: -- **Stable prices** (volatility filter) - "Only show if price doesn't fluctuate much"- **Stable prices** (volatility filter) - "Only show if price doesn't fluctuate much" +**What:** Automatically loosen filters if not enough periods are found -- **Absolute quality** (level filter) - "Only show if prices are CHEAP/EXPENSIVE (not just below/above average)"- **Absolute quality** (level filter) - "Only show if prices are CHEAP/EXPENSIVE (not just below/above average)" +**Default:** Enabled- **Stable prices** (volatility filter) - "Only show if price doesn't fluctuate much"- **Stable prices** (volatility filter) - "Only show if price doesn't fluctuate much" +```yaml- **Absolute quality** (level filter) - "Only show if prices are CHEAP/EXPENSIVE (not just below/above average)"- **Absolute quality** (level filter) - "Only show if prices are CHEAP/EXPENSIVE (not just below/above average)" + +enable_min_periods_best: true # Try to find at least min_periods + +min_periods_best: 1 # Target: 1 period per day + +``` + ### Visual Example### Visual Example +#### Relaxation Step Size -**Timeline for a typical day:****Timeline for a typical day:** + +**What:** How much to increase flexibility per relaxation phase + +**Default:** 25%**Timeline for a typical day:****Timeline for a typical day:** + +**Range:** 10-50% `````` -Hour: 00 01 02 03 04 05 06 07 08 09 10 11 12 13 14 15 16 17 18 19 20 21 22 23Hour: 00 01 02 03 04 05 06 07 08 09 10 11 12 13 14 15 16 17 18 19 20 21 22 23 +```yaml + +relaxation_step_best: 25 # Increase flex by 25% per phaseHour: 00 01 02 03 04 05 06 07 08 09 10 11 12 13 14 15 16 17 18 19 20 21 22 23Hour: 00 01 02 03 04 05 06 07 08 09 10 11 12 13 14 15 16 17 18 19 20 21 22 23 + +``` Price: 18 19 20 28 29 30 35 34 33 32 30 28 25 24 26 28 30 32 31 22 21 20 19 18Price: 18 19 20 28 29 30 35 34 33 32 30 28 25 24 26 28 30 32 31 22 21 20 19 18 +**Example:** With base flex 15% and step 25%: + +- Phase 1: 15% (original) + +- Phase 2: 18.75% (15% × 1.25) + +- Phase 3: 23.44% (18.75% × 1.25)Daily MIN: 18 ct | Daily MAX: 35 ct | Daily AVG: 26 ctDaily MIN: 18 ct | Daily MAX: 35 ct | Daily AVG: 26 ct + +- Phase 4: 29.3% (23.44% × 1.25) -Daily MIN: 18 ct | Daily MAX: 35 ct | Daily AVG: 26 ctDaily MIN: 18 ct | Daily MAX: 35 ct | Daily AVG: 26 ct - +--- Best Price (15% flex = ≤20.7 ct):Best Price (15% flex = ≤20.7 ct): +## Understanding Relaxation + ████████ ████████████████ ████████ ████████████████ +### Why Relaxation? + 00:00-03:00 (3h) 19:00-24:00 (5h) 00:00-03:00 (3h) 19:00-24:00 (5h) +Some days have unusual price patterns: +- **Flat curves** (all prices very similar) → Hard to find periods significantly below/above average -Peak Price (-15% flex = ≥29.75 ct):Peak Price (-15% flex = ≥29.75 ct): +- **Missing extremes** (no CHEAP/EXPENSIVE intervals) → Level filters block everything - ████████████████████████ ████████████████████████ - - 06:00-11:00 (5h) 06:00-11:00 (5h) - -`````` +- **Short cheap windows** (only 15-30 min blocks) → Can't meet minimum lengthPeak Price (-15% flex = ≥29.75 ct):Peak Price (-15% flex = ≥29.75 ct): ------- +Relaxation ensures you still get notified about the best opportunities available, even on difficult days. ████████████████████████ ████████████████████████ -## Configuration Guide## Configuration Guide +### The 4×3 Relaxation Matrix 06:00-11:00 (5h) 06:00-11:00 (5h) +When enabled, relaxation tries **4 flexibility levels** × **3 filter combinations** = 12 strategies:`````` + + + +#### 4 Flexibility Levels: + +1. Original (e.g., 15%) + +2. Step 1 (e.g., 18.75%)------ + +3. Step 2 (e.g., 23.44%) + +4. Step 3 (e.g., 29.3%) + + + +#### 3 Filter Combinations (per flexibility level):## Configuration Guide## Configuration Guide + +1. **Original filters** (flex + min_distance + level_filter) + +2. **Remove level filter** (flex + min_distance + level_override=any) + +3. **Remove both** (flex + level_override=any + min_distance_override=0%) + ### Basic Settings### Basic Settings - - -#### Flexibility#### Flexibility +### Relaxation Flow -**What:** How far from MIN/MAX to search for periods **What:** How far from MIN/MAX to search for periods +``` -**Default:** 15% (Best Price), -15% (Peak Price) **Default:** 15% (Best Price), -15% (Peak Price) - -**Range:** 0-100%**Range:** 0-100% +Start: Flex 15% + min_distance 5% + level CHEAP#### Flexibility#### Flexibility +Phase 1: Flex 15.0% + Original filters → Not enough periods + +Phase 2: Flex 15.0% + Level=any → Not enough periods + +Phase 3: Flex 15.0% + All filters off → Not enough periods**What:** How far from MIN/MAX to search for periods **What:** How far from MIN/MAX to search for periods + + + +Phase 4: Flex 18.75% + Original filters → Not enough periods**Default:** 15% (Best Price), -15% (Peak Price) **Default:** 15% (Best Price), -15% (Peak Price) + +Phase 5: Flex 18.75% + Level=any → SUCCESS! Found 2 periods ✓ + +→ Stop early (no need to try higher flex)**Range:** 0-100%**Range:** 0-100% + +``` + + + +### Tracking Relaxation + ```yaml```yaml +The integration tracks which filters were applied via the `relaxation_level` attribute: + best_price_flex: 15 # Can be up to 15% more expensive than daily MINbest_price_flex: 15 # Can be up to 15% more expensive than daily MIN -peak_price_flex: -15 # Can be up to 15% less expensive than daily MAXpeak_price_flex: -15 # Can be up to 15% less expensive than daily MAX +```yaml + +relaxation_level: "flex_18.75_level_any"peak_price_flex: -15 # Can be up to 15% less expensive than daily MAXpeak_price_flex: -15 # Can be up to 15% less expensive than daily MAX + +``` `````` +**Possible values:** +- `none` - Original filters worked -**When to adjust:****When to adjust:** +- `flex_X.XX` - Increased flexibility (X.XX%) + +- `flex_X.XX_level_any` - Increased flex + disabled level filter**When to adjust:****When to adjust:** + +- `flex_X.XX_all_off` - Increased flex + all filters disabled - **Increase (20-25%)** → Find more/longer periods- **Increase (20-25%)** → Find more/longer periods +--- + - **Decrease (5-10%)** → Find only the very best/worst times- **Decrease (5-10%)** → Find only the very best/worst times +## Common Scenarios + +### Scenario 1: Simple Best Price (Default) + #### Minimum Period Length#### Minimum Period Length +**Goal:** Find cheapest 1-hour windows -**What:** How long a period must be to show it **What:** How long a period must be to show it -**Default:** 60 minutes **Default:** 60 minutes +```yaml -**Range:** 15-240 minutes**Range:** 15-240 minutes +best_price_min_period_length: 60**What:** How long a period must be to show it **What:** How long a period must be to show it + +best_price_flex: 15 + +best_price_min_distance_from_avg: 5 # (default)**Default:** 60 minutes **Default:** 60 minutes + +best_price_max_level: any + +enable_min_periods_best: true**Range:** 15-240 minutes**Range:** 15-240 minutes + +``` +**Result:** Finds any 1-hour+ period with prices ≤15% above minimum AND ≥5% below daily average. + ```yaml```yaml +### Scenario 2: Strict Best Price (Only Objectively Cheap) + best_price_min_period_length: 60best_price_min_period_length: 60 +**Goal:** Only show best price when prices are truly CHEAP + peak_price_min_period_length: 60peak_price_min_period_length: 60 -`````` +```yaml + +best_price_min_period_length: 60`````` + +best_price_flex: 10 # Tighter tolerance + +best_price_min_distance_from_avg: 8 # Much better than average + +best_price_max_level: cheap # Must have CHEAP intervals + +best_price_max_level_gap_count: 1 # Allow 1 gap**When to adjust:****When to adjust:** + +enable_min_periods_best: true + +```- **Increase (90-120 min)** → Only show longer periods (e.g., for heat pump cycles)- **Increase (90-120 min)** → Only show longer periods (e.g., for heat pump cycles) -**When to adjust:****When to adjust:** - -- **Increase (90-120 min)** → Only show longer periods (e.g., for heat pump cycles)- **Increase (90-120 min)** → Only show longer periods (e.g., for heat pump cycles) - -- **Decrease (30-45 min)** → Show shorter windows (e.g., for quick tasks)- **Decrease (30-45 min)** → Show shorter windows (e.g., for quick tasks) +**Result:** Very selective - only shows periods that are significantly and objectively cheap.- **Decrease (30-45 min)** → Show shorter windows (e.g., for quick tasks)- **Decrease (30-45 min)** → Show shorter windows (e.g., for quick tasks) -#### Distance from Average#### Distance from Average +### Scenario 3: Relaxed Best Price (More Opportunities) -**What:** How much better than average a period must be **What:** How much better than average a period must be - -**Default:** 2% **Default:** 2% - -**Range:** 0-20%**Range:** 0-20% +**Goal:** Show more best price periods, even marginal ones#### Distance from Average#### Distance from Average -```yaml```yaml +```yaml -best_price_min_distance_from_avg: 2best_price_min_distance_from_avg: 2 +best_price_min_period_length: 45 # Shorter periods OK -peak_price_min_distance_from_avg: 2peak_price_min_distance_from_avg: 2 +best_price_flex: 25 # Wider tolerance**What:** How much better than average a period must be **What:** How much better than average a period must be -`````` +best_price_min_distance_from_avg: 2 # Less strict + +best_price_max_level: any**Default:** 2% **Default:** 2% + +enable_min_periods_best: true + +```**Range:** 0-20%**Range:** 0-20% -**When to adjust:****When to adjust:** - -- **Increase (5-10%)** → Only show clearly better times- **Increase (5-10%)** → Only show clearly better times - -- **Decrease (0-1%)** → Show any time below/above average- **Decrease (0-1%)** → Show any time below/above average +**Result:** Shows more periods, including those only slightly cheaper than average. -### Optional Filters### Optional Filters +### Scenario 4: Peak Price Warning (Avoid Expensive Times)```yaml```yaml -#### Volatility Filter (Price Stability)#### Volatility Filter (Price Stability) +**Goal:** Get warned about expensive periodsbest_price_min_distance_from_avg: 2best_price_min_distance_from_avg: 2 +```yamlpeak_price_min_distance_from_avg: 2peak_price_min_distance_from_avg: 2 + +peak_price_min_period_length: 60 + +peak_price_flex: -15`````` + +peak_price_min_distance_from_avg: 5 + +peak_price_min_level: expensive # Must be objectively expensive + +enable_min_periods_peak: true + +```**When to adjust:****When to adjust:** + + + +**Result:** Shows peak prices only when they're significantly above average AND contain EXPENSIVE intervals.- **Increase (5-10%)** → Only show clearly better times- **Increase (5-10%)** → Only show clearly better times + + + +### Scenario 5: Match Long Appliance Cycle- **Decrease (0-1%)** → Show any time below/above average- **Decrease (0-1%)** → Show any time below/above average + + + +**Goal:** Find 3-hour window for slow dishwasher ECO program + + + +```yaml### Optional Filters### Optional Filters + +best_price_min_period_length: 180 # 3 hours minimum + +best_price_flex: 20 # More flexibility to find longer periods + +best_price_min_distance_from_avg: 5 + +enable_min_periods_best: true#### Volatility Filter (Price Stability)#### Volatility Filter (Price Stability) + +``` + + + +**Result:** Finds 3+ hour windows, may relax flexibility if needed. + **What:** Only show periods with stable prices (low fluctuation) **What:** Only show periods with stable prices (low fluctuation) +--- + **Default:** `low` (disabled) **Default:** `low` (disabled) +## Troubleshooting + **Options:** `low` | `moderate` | `high` | `very_high`**Options:** `low` | `moderate` | `high` | `very_high` +### No Periods Found + +**Symptom:** `binary_sensor.tibber_home_best_price_period` never turns "on" + ```yaml```yaml +**Possible causes:** + best_price_min_volatility: low # Show all periodsbest_price_min_volatility: low # Show all periods +1. **Filters too strict** + best_price_min_volatility: moderate # Only show if price doesn't swing >5 ctbest_price_min_volatility: moderate # Only show if price doesn't swing >5 ct -`````` + ```yaml + # Try:`````` + best_price_flex: 20 # Increase from default 15% + + best_price_min_distance_from_avg: 2 # Reduce from default 5% + + ``` **Use case:** "I want predictable prices during the period"**Use case:** "I want predictable prices during the period" - - -#### Level Filter (Absolute Quality)#### Level Filter (Absolute Quality) +2. **Period length too long** -**What:** Only show periods with CHEAP/EXPENSIVE intervals (not just below/above average) **What:** Only show periods with CHEAP/EXPENSIVE intervals (not just below/above average) + ```yaml -**Default:** `any` (disabled) **Default:** `any` (disabled) + # Try:#### Level Filter (Absolute Quality)#### Level Filter (Absolute Quality) -**Options:** `any` | `cheap` | `very_cheap` (Best Price) | `expensive` | `very_expensive` (Peak Price)**Options:** `any` | `cheap` | `very_cheap` (Best Price) | `expensive` | `very_expensive` (Peak Price) + best_price_min_period_length: 45 # Reduce from 60 minutes + + ``` +3. **Flat price curve** (all prices very similar)**What:** Only show periods with CHEAP/EXPENSIVE intervals (not just below/above average) **What:** Only show periods with CHEAP/EXPENSIVE intervals (not just below/above average) + + + + Solution: Enable relaxation (should be default)**Default:** `any` (disabled) **Default:** `any` (disabled) + + ```yaml + + enable_min_periods_best: true**Options:** `any` | `cheap` | `very_cheap` (Best Price) | `expensive` | `very_expensive` (Peak Price)**Options:** `any` | `cheap` | `very_cheap` (Best Price) | `expensive` | `very_expensive` (Peak Price) + + ``` + + + +4. **Level filter too strict** + ```yaml```yaml -best_price_max_level: any # Show any period below averagebest_price_max_level: any # Show any period below average + ```yaml -best_price_max_level: cheap # Only show if at least one interval is CHEAPbest_price_max_level: cheap # Only show if at least one interval is CHEAP + # Try:best_price_max_level: any # Show any period below averagebest_price_max_level: any # Show any period below average -`````` + best_price_max_level: any # Disable level filter + + ```best_price_max_level: cheap # Only show if at least one interval is CHEAPbest_price_max_level: cheap # Only show if at least one interval is CHEAP -**Use case:** "Only notify me when prices are objectively cheap/expensive"**Use case:** "Only notify me when prices are objectively cheap/expensive" +### Too Many Periods`````` -#### Gap Tolerance (for Level Filter)#### Gap Tolerance (for Level Filter) +**Symptom:** Best price is "on" most of the day -**What:** Allow some "mediocre" intervals within an otherwise good period **What:** Allow some "mediocre" intervals within an otherwise good period - -**Default:** 0 (strict) **Default:** 0 (strict) - -**Range:** 0-10**Range:** 0-10 +**Solution:** Make filters stricter**Use case:** "Only notify me when prices are objectively cheap/expensive"**Use case:** "Only notify me when prices are objectively cheap/expensive" -```yaml```yaml +```yaml -best_price_max_level: cheapbest_price_max_level: cheap +best_price_flex: 10 # Reduce from default 15% -best_price_max_level_gap_count: 2 # Allow up to 2 NORMAL intervals per periodbest_price_max_level_gap_count: 2 # Allow up to 2 NORMAL intervals per period +best_price_min_distance_from_avg: 8 # Increase from default 5%#### Gap Tolerance (for Level Filter)#### Gap Tolerance (for Level Filter) -`````` +best_price_max_level: cheap # Require CHEAP intervals + +``` +### Periods Too Short**What:** Allow some "mediocre" intervals within an otherwise good period **What:** Allow some "mediocre" intervals within an otherwise good period + + + +**Symptom:** Best price periods are only 15-30 minutes**Default:** 0 (strict) **Default:** 0 (strict) + + + +**Solution:****Range:** 0-10**Range:** 0-10 + + + +```yaml + +best_price_min_period_length: 90 # Increase minimum length + +``````yaml```yaml + + + +### Wrong Level Filter Resultsbest_price_max_level: cheapbest_price_max_level: cheap + + + +**Symptom:** Period shows even though no CHEAP intervals existbest_price_max_level_gap_count: 2 # Allow up to 2 NORMAL intervals per periodbest_price_max_level_gap_count: 2 # Allow up to 2 NORMAL intervals per period + + + +**Check:**`````` + +1. Look at `relaxation_level` attribute - is it `level_any`? + +2. If yes, relaxation disabled the level filter because no periods were found + +3. Solution: Disable relaxation or increase target periods + **Use case:** "Don't split periods just because one interval isn't perfectly CHEAP"**Use case:** "Don't split periods just because one interval isn't perfectly CHEAP" +```yaml + +enable_min_periods_best: false # Strict mode + +# OR + +min_periods_best: 2 # Try harder to keep filters------ + +``` ------- - +--- ## Understanding Relaxation## Understanding Relaxation +## Advanced Topics + +### Per-Day Independence + ### What Is Relaxation?### What Is Relaxation? +**Important:** Relaxation operates **independently per day**. Each day (today/tomorrow) has its own relaxation state. -Sometimes, strict filters find too few periods (or none). **Relaxation automatically loosens filters** until a minimum number of periods is found.Sometimes, strict filters find too few periods (or none). **Relaxation automatically loosens filters** until a minimum number of periods is found. +**Example:** +```yamlSometimes, strict filters find too few periods (or none). **Relaxation automatically loosens filters** until a minimum number of periods is found.Sometimes, strict filters find too few periods (or none). **Relaxation automatically loosens filters** until a minimum number of periods is found. + +# 2025-11-11 (today): + +Baseline: Found 2 periods → SUCCESS (no relaxation) + +relaxation_level: "none" ### How to Enable### How to Enable +# 2025-11-12 (tomorrow): + +Baseline: Found 0 periods → Start relaxation + +Phase 1: flex 18.75% → Found 1 period → PARTIAL + +Phase 2: flex 23.44% + level=any → Found 2 periods → SUCCESS```yaml```yaml + +relaxation_level: "flex_23.44_level_any" + +```enable_min_periods_best: trueenable_min_periods_best: true -```yaml```yaml -enable_min_periods_best: trueenable_min_periods_best: true +This ensures that:min_periods_best: 2 # Try to find at least 2 periods per daymin_periods_best: 2 # Try to find at least 2 periods per day -min_periods_best: 2 # Try to find at least 2 periods per daymin_periods_best: 2 # Try to find at least 2 periods per day +- Good days keep strict filters -relaxation_step_best: 35 # Increase flex by 35% per step (e.g., 15% → 20.25% → 27.3%)relaxation_step_best: 35 # Increase flex by 35% per step (e.g., 15% → 20.25% → 27.3%) +- Difficult days get helprelaxation_step_best: 35 # Increase flex by 35% per step (e.g., 15% → 20.25% → 27.3%)relaxation_step_best: 35 # Increase flex by 35% per step (e.g., 15% → 20.25% → 27.3%) + +- Each day is evaluated fairly `````` +### Period Merging and Replacement + +When building periods, the algorithm: + ### How It Works (Smart 4×4 Matrix)### How It Works (New Smart Strategy) +1. **Merges adjacent qualifying intervals** into continuous periods + +2. **Replaces smaller periods** with larger overlapping ones``` + + + +**Example:**Relaxation uses a **4×4 matrix approach** - trying 4 flexibility levels with 4 different filter combinations (16 attempts total per day):Found periods: + ``` -Relaxation uses a **4×4 matrix approach** - trying 4 flexibility levels with 4 different filter combinations (16 attempts total per day):Found periods: +Baseline finds: 00:00-01:00 (1h), 00:30-01:30 (1h)- 00:00-01:00 (60 min) ✓ Keep -- 00:00-01:00 (60 min) ✓ Keep +→ Larger period (00:30-01:30) replaces smaller one (00:00-01:00) -#### Phase Matrix- 03:00-03:30 (30 min) ✗ Discard (too short) - -- 14:00-15:15 (75 min) ✓ Keep - -For each day, the system tries:``` +→ Result: Single period 00:30-01:30#### Phase Matrix- 03:00-03:30 (30 min) ✗ Discard (too short) +Relaxation adds: 00:15-01:45 (1.5h)- 14:00-15:15 (75 min) ✓ Keep + +→ Even larger period replaces previous + +→ Result: Single period 00:15-01:45For each day, the system tries:``` + +``` + + + +This prevents overlapping periods and ensures you get the **longest possible cheap/expensive window**. + **4 Flexibility Levels:** +### Early Stop Optimization + 1. Original (e.g., 15%)### How It Works (New Smart Strategy) +Relaxation stops as soon as the target period count is reached: + 2. +35% step (e.g., 20.25%) -3. +35% step (e.g., 27.3%)Relaxation uses a **4×4 matrix approach** - trying 4 flexibility levels with 4 different filter combinations (16 attempts total per day): +``` -4. +35% step (e.g., 36.9%) +Target: 2 periods3. +35% step (e.g., 27.3%)Relaxation uses a **4×4 matrix approach** - trying 4 flexibility levels with 4 different filter combinations (16 attempts total per day): -#### Phase Matrix -**4 Filter Combinations (per flexibility level):** + +Phase 1: Flex 15.0% + Original → 0 periods4. +35% step (e.g., 36.9%) + +Phase 2: Flex 15.0% + Level=any → 1 period (partial) + +Phase 3: Flex 15.0% + All off → 2 periods ✓ STOP#### Phase Matrix + + + +No need to try flex 18.75%, 23.44%, 29.3%!**4 Filter Combinations (per flexibility level):** + +``` 1. Original filters (your configured volatility + level)For each day, the system tries: -2. Remove volatility filter (keep level filter) +This: -3. Remove level filter (keep volatility filter)**4 Flexibility Levels:** +- Keeps filters as strict as possible2. Remove volatility filter (keep level filter) -4. Remove both filters1. Original (e.g., 15%) +- Reduces computation time -2. +35% step (e.g., 20.25%) +- Provides predictable behavior3. Remove level filter (keep volatility filter)**4 Flexibility Levels:** -**Example progression:**3. +35% step (e.g., 27.3%) -```4. +35% step (e.g., 36.9%) -Flex 15% + Original filters → Not enough periods +### Volatility Information (Not a Filter)4. Remove both filters1. Original (e.g., 15%) -Flex 15% + Volatility=any → Not enough periods**4 Filter Combinations (per flexibility level):** -Flex 15% + Level=any → Not enough periods1. Original filters (your configured volatility + level) -Flex 15% + All filters off → Not enough periods2. Remove volatility filter (keep level filter) +**Important:** Volatility is **NOT** used as a filter. It's calculated as an **information attribute** for each period:2. +35% step (e.g., 20.25%) -Flex 20.25% + Original → SUCCESS! Found 2 periods ✓3. Remove level filter (keep volatility filter) -(stops here - no need to try more)4. Remove both filters + +```yaml**Example progression:**3. +35% step (e.g., 27.3%) + +attributes: + + volatility: "LOW" # Info only```4. +35% step (e.g., 36.9%) + + volatility_numeric: 2.3 # Spread in ct/kWh + +```Flex 15% + Original filters → Not enough periods + + + +**Volatility levels:**Flex 15% + Volatility=any → Not enough periods**4 Filter Combinations (per flexibility level):** + +- `LOW`: Spread < 5 ct (stable prices) + +- `MODERATE`: 5 ≤ spread < 15 ctFlex 15% + Level=any → Not enough periods1. Original filters (your configured volatility + level) + +- `HIGH`: 15 ≤ spread < 30 ct + +- `VERY_HIGH`: spread ≥ 30 ctFlex 15% + All filters off → Not enough periods2. Remove volatility filter (keep level filter) + + + +**Use case:** You can use this information in automations, but it doesn't affect which periods are found.Flex 20.25% + Original → SUCCESS! Found 2 periods ✓3. Remove level filter (keep volatility filter) + + + +### Level Filter Implementation(stops here - no need to try more)4. Remove both filters + + + +The level filter applies **during interval qualification**, not as a post-filter:``` + + + +```python**Example progression:** + +# Simplified logic + +for interval in all_intervals:#### Per-Day Independence``` + + in_flex = check_flexibility(interval, extreme) + + meets_min_distance = check_min_distance(interval, average)Flex 15% + Original filters → Not enough periods + + meets_level = check_level(interval, level_filter) # Applied here + + **Critical:** Each day relaxes **independently**:Flex 15% + Volatility=any → Not enough periods + + if in_flex and meets_min_distance and meets_level: + + add_to_candidates(interval)Flex 15% + Level=any → Not enough periods ``` -**Example progression:** - -#### Per-Day Independence``` - -Flex 15% + Original filters → Not enough periods - -**Critical:** Each day relaxes **independently**:Flex 15% + Volatility=any → Not enough periods - -Flex 15% + Level=any → Not enough periods - ```Flex 15% + All filters off → Not enough periods -Day 1: Finds 2 periods with flex 15% (original) → No relaxation neededFlex 20.25% + Original → SUCCESS! Found 2 periods ✓ +This means: -Day 2: Needs flex 27.3% + level=any → Uses relaxed settings(stops here - no need to try more) +- Intervals are filtered **before** period buildingDay 1: Finds 2 periods with flex 15% (original) → No relaxation neededFlex 20.25% + Original → SUCCESS! Found 2 periods ✓ -Day 3: Finds 2 periods with flex 15% (original) → No relaxation needed``` +- Gap tolerance prevents splitting during period construction -``` +- More efficient than filtering complete periodsDay 2: Needs flex 27.3% + level=any → Uses relaxed settings(stops here - no need to try more) -#### Per-Day Independence -**Why?** Price patterns vary daily. Some days have clear cheap/expensive windows (strict filters work), others don't (relaxation needed). -**Critical:** Each day relaxes **independently**: +### Configuration PersistenceDay 3: Finds 2 periods with flex 15% (original) → No relaxation needed``` -#### Period Replacement Logic -``` -When relaxation finds new periods, they interact with baseline periods in two ways:Day 1: Finds 2 periods with flex 15% (original) → No relaxation needed +Period configuration is stored in Home Assistant's config entry options and persists across restarts. Changes take effect immediately without restarting HA.``` -Day 2: Needs flex 27.3% + level=any → Uses relaxed settings -**1. Extension** (Enlargement)Day 3: Finds 2 periods with flex 15% (original) → No relaxation needed + +---#### Per-Day Independence + + + +## Quick Reference**Why?** Price patterns vary daily. Some days have clear cheap/expensive windows (strict filters work), others don't (relaxation needed). + + + +### Parameter Summary (Best Price)**Critical:** Each day relaxes **independently**: + + + +| Parameter | Default | Range | Purpose |#### Period Replacement Logic + +|-----------|---------|-------|---------| + +| `best_price_min_period_length` | 60 min | 15-480 min | Minimum duration |``` + +| `best_price_flex` | 15% | 0-30% | Tolerance above min | + +| `best_price_min_distance_from_avg` | 5% | 0-20% | Quality threshold |When relaxation finds new periods, they interact with baseline periods in two ways:Day 1: Finds 2 periods with flex 15% (original) → No relaxation needed + +| `best_price_max_level` | any | any/cheap/very_cheap | Level filter | + +| `best_price_max_level_gap_count` | 0 | 0-5 | Gap tolerance |Day 2: Needs flex 27.3% + level=any → Uses relaxed settings + +| `enable_min_periods_best` | true | true/false | Enable relaxation | + +| `min_periods_best` | 1 | 1-5 | Target periods |**1. Extension** (Enlargement)Day 3: Finds 2 periods with flex 15% (original) → No relaxation needed + +| `relaxation_step_best` | 25% | 10-50% | Flex increase step | A relaxed period that **overlaps** with a baseline period and extends it:``` +**Peak Price:** Same parameters with `peak_price_*` prefix (defaults: flex=-15%, level options: expensive/very_expensive) + ``` +### Filter Priority (Order of Application) + Baseline: [14:00-16:00] ████████**Why?** Price patterns vary daily. Some days have clear cheap/expensive windows (strict filters work), others don't (relaxation needed). -Relaxed: [13:00-16:30] ████████████ +1. **Flexibility** - Within X% of extreme (REQUIRED) -Result: [13:00-16:30] ████████████████ (baseline expanded)#### Period Replacement Logic +2. **Level Filter** - Must have CHEAP/EXPENSIVE intervals (OPTIONAL)Relaxed: [13:00-16:30] ████████████ + +3. **Gap Tolerance** - Allow N deviating intervals (with level filter only) + +4. **Min Distance** - Must be X% better than average (REQUIRED)Result: [13:00-16:30] ████████████████ (baseline expanded)#### Period Replacement Logic + +5. **Min Length** - Period must be ≥X minutes (REQUIRED) ↑ Keeps baseline metadata (original flex/filters) +### Relaxation Priority (Order of Loosening) + ```When relaxation finds new periods, they interact with baseline periods in two ways: +1. Keep all filters, increase flex +2. Disable level filter, keep min_distance + +3. Disable both level and min_distance filters **2. Replacement** (Substitution)**1. Extension** (Enlargement) +--- + A **larger** relaxed period completely contains a **smaller** relaxed period from earlier phases:A relaxed period that **overlaps** with a baseline period and extends it: +## Related Documentation + `````` -Phase 1: [14:00-15:00] ████ (found with flex 15%)Baseline: [14:00-16:00] ████████ +- **[Configuration Guide](configuration.md)** - Full integration setup + +- **[Sensors Reference](sensors.md)** - All available sensorsPhase 1: [14:00-15:00] ████ (found with flex 15%)Baseline: [14:00-16:00] ████████ + +- **[Automation Examples](automation-examples.md)** - Ready-to-use automations + +- **[Troubleshooting](troubleshooting.md)** - Common issues and solutionsPhase 3: [13:00-17:00] ████████████ (found with flex 27.3%)Relaxed: [13:00-16:30] ████████████ -Phase 3: [13:00-17:00] ████████████ (found with flex 27.3%)Relaxed: [13:00-16:30] ████████████ Result: [13:00-17:00] ████████████ (larger replaces smaller)Result: [13:00-16:30] ████████████████ (baseline expanded)