From 07517660e335a9a207b82ff7c70930f8f6efd83d Mon Sep 17 00:00:00 2001 From: Julian Pawlowski Date: Fri, 14 Nov 2025 01:12:47 +0000 Subject: [PATCH] refactor(volatility): migrate to coefficient of variation calculation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaced absolute volatility thresholds (ct/øre) with relative coefficient of variation (CV = std_dev / mean * 100%) for scale-independent volatility measurement that works across all price levels. Changes to volatility calculation: - price_utils.py: Rewrote calculate_volatility_level() to accept price list instead of spread value, using statistics.mean() and statistics.stdev() - sensor.py: Updated volatility sensors to pass price lists (not spread) - services.py: Modified _get_price_stats() to calculate CV from prices - period_statistics.py: Extract prices for CV calculation in period summaries - const.py: Updated default thresholds to 15%/30%/50% (was 5/15/30 ct) with comprehensive documentation explaining CV-based approach Dead code removal: - period_utils/core.py: Removed filter_periods_by_volatility() function (86 lines of code that was never actually called) - period_utils/__init__.py: Removed dead function export - period_utils/relaxation.py: Simplified callback signature from Callable[[str|None, str|None], bool] to Callable[[str|None], bool] - coordinator.py: Updated lambda callbacks to match new signature - const.py: Replaced RELAXATION_VOLATILITY_ANY with RELAXATION_LEVEL_ANY Bug fix: - relaxation.py: Added int() conversion for max_relaxation_attempts (line 435: attempts = max(1, int(max_relaxation_attempts))) Fixes TypeError when config value arrives as float Configuration UI: - config_flow.py: Changed volatility threshold unit display from "ct" to "%" Translations (all 5 languages): - Updated volatility descriptions to explain coefficient of variation - Changed threshold labels from "spread ≥ value" to "CV ≥ percentage" - Languages: de, en, nb, nl, sv Documentation: - period-calculation.md: Removed volatility filter section (dead feature) Impact: Breaking change for users with custom volatility thresholds. Old absolute values (e.g., 5 ct) will be interpreted as percentages (5%). However, new defaults (15%/30%/50%) are more conservative and work universally across all currencies and price levels. No data migration needed - existing configs continue to work with new interpretation. --- .../tibber_prices/config_flow.py | 6 +- custom_components/tibber_prices/const.py | 17 ++-- .../tibber_prices/coordinator.py | 7 +- .../tibber_prices/period_utils/__init__.py | 3 +- .../tibber_prices/period_utils/core.py | 85 ------------------- .../period_utils/period_statistics.py | 7 +- .../tibber_prices/period_utils/relaxation.py | 32 +++---- .../tibber_prices/price_utils.py | 44 +++++++--- custom_components/tibber_prices/sensor.py | 19 +++-- custom_components/tibber_prices/services.py | 10 +-- .../tibber_prices/translations/de.json | 8 +- .../tibber_prices/translations/en.json | 8 +- .../tibber_prices/translations/nb.json | 8 +- .../tibber_prices/translations/nl.json | 8 +- .../tibber_prices/translations/sv.json | 8 +- docs/user/period-calculation.md | 43 +++------- 16 files changed, 115 insertions(+), 198 deletions(-) diff --git a/custom_components/tibber_prices/config_flow.py b/custom_components/tibber_prices/config_flow.py index 208a06b..8496a9b 100644 --- a/custom_components/tibber_prices/config_flow.py +++ b/custom_components/tibber_prices/config_flow.py @@ -969,7 +969,7 @@ class TibberPricesOptionsFlowHandler(OptionsFlow): min=0.0, max=100.0, step=0.1, - unit_of_measurement="ct", + unit_of_measurement="%", mode=NumberSelectorMode.BOX, ), ), @@ -986,7 +986,7 @@ class TibberPricesOptionsFlowHandler(OptionsFlow): min=0.0, max=100.0, step=0.1, - unit_of_measurement="ct", + unit_of_measurement="%", mode=NumberSelectorMode.BOX, ), ), @@ -1003,7 +1003,7 @@ class TibberPricesOptionsFlowHandler(OptionsFlow): min=0.0, max=100.0, step=0.1, - unit_of_measurement="ct", + unit_of_measurement="%", mode=NumberSelectorMode.BOX, ), ), diff --git a/custom_components/tibber_prices/const.py b/custom_components/tibber_prices/const.py index b054556..c047c6a 100644 --- a/custom_components/tibber_prices/const.py +++ b/custom_components/tibber_prices/const.py @@ -69,9 +69,12 @@ DEFAULT_PRICE_RATING_THRESHOLD_LOW = -10 # Default rating threshold low percent DEFAULT_PRICE_RATING_THRESHOLD_HIGH = 10 # Default rating threshold high percentage DEFAULT_PRICE_TREND_THRESHOLD_RISING = 5 # Default trend threshold for rising prices (%) DEFAULT_PRICE_TREND_THRESHOLD_FALLING = -5 # Default trend threshold for falling prices (%, negative value) -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 volatility thresholds (relative values using coefficient of variation) +# Coefficient of variation = (standard_deviation / mean) * 100% +# These thresholds are unitless and work across different price levels +DEFAULT_VOLATILITY_THRESHOLD_MODERATE = 15.0 # 15% - moderate price fluctuation +DEFAULT_VOLATILITY_THRESHOLD_HIGH = 30.0 # 30% - high price fluctuation +DEFAULT_VOLATILITY_THRESHOLD_VERY_HIGH = 50.0 # 50% - very high price fluctuation 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) @@ -172,7 +175,7 @@ PRICE_RATING_LOW = "LOW" PRICE_RATING_NORMAL = "NORMAL" PRICE_RATING_HIGH = "HIGH" -# Price volatility levels (based on spread between min and max) +# Price volatility levels (based on coefficient of variation: std_dev / mean * 100%) VOLATILITY_LOW = "LOW" VOLATILITY_MODERATE = "MODERATE" VOLATILITY_HIGH = "HIGH" @@ -213,7 +216,7 @@ BEST_PRICE_MAX_LEVEL_OPTIONS = [ PRICE_LEVEL_EXPENSIVE.lower(), # Only show if level ≤ EXPENSIVE ] -# Valid options for peak price minimum level filter (AND-linked with volatility filter) +# Valid options for peak price minimum level filter # Sorted from expensive to cheap: user selects "starting from how expensive" PEAK_PRICE_MIN_LEVEL_OPTIONS = [ "any", # No filter, allow all price levels @@ -226,8 +229,8 @@ PEAK_PRICE_MIN_LEVEL_OPTIONS = [ # Relaxation level constants (for period filter relaxation) # These describe which filter relaxation was applied to find a period RELAXATION_NONE = "none" # No relaxation, normal filters -RELAXATION_VOLATILITY_ANY = "volatility_any" # Volatility filter disabled -RELAXATION_ALL_FILTERS_OFF = "all_filters_off" # All filters disabled (last resort) +RELAXATION_LEVEL_ANY = "level_any" # Level filter disabled +RELAXATION_ALL_FILTERS_OFF = "all_filters_off" # All filters disabled (deprecated, same as level_any) # Mapping for comparing price levels (used for sorting) PRICE_LEVEL_MAPPING = { diff --git a/custom_components/tibber_prices/coordinator.py b/custom_components/tibber_prices/coordinator.py index a9f1a8a..63fa603 100644 --- a/custom_components/tibber_prices/coordinator.py +++ b/custom_components/tibber_prices/coordinator.py @@ -762,9 +762,6 @@ class TibberPricesDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): """ Check if periods should be shown based on level filter only. - Note: Volatility filtering is now applied per-period after calculation, - not at the daily level. See _filter_periods_by_volatility(). - Args: price_info: Price information dict with today/yesterday/tomorrow data reverse_sort: If False (best_price), checks max_level filter. @@ -1207,7 +1204,7 @@ class TibberPricesDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): min_periods=min_periods_best, relaxation_step_pct=relaxation_step_best, max_relaxation_attempts=relaxation_attempts_best, - should_show_callback=lambda _vol, lvl: self._should_show_periods( + should_show_callback=lambda lvl: self._should_show_periods( price_info, reverse_sort=False, level_override=lvl, @@ -1279,7 +1276,7 @@ class TibberPricesDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): min_periods=min_periods_peak, relaxation_step_pct=relaxation_step_peak, max_relaxation_attempts=relaxation_attempts_peak, - should_show_callback=lambda _vol, lvl: self._should_show_periods( + should_show_callback=lambda lvl: self._should_show_periods( price_info, reverse_sort=True, level_override=lvl, diff --git a/custom_components/tibber_prices/period_utils/__init__.py b/custom_components/tibber_prices/period_utils/__init__.py index 88b02eb..deca6c3 100644 --- a/custom_components/tibber_prices/period_utils/__init__.py +++ b/custom_components/tibber_prices/period_utils/__init__.py @@ -17,7 +17,7 @@ All public APIs are re-exported for backwards compatibility. from __future__ import annotations # Re-export main API functions -from .core import calculate_periods, filter_periods_by_volatility +from .core import calculate_periods # Re-export outlier filtering from .outlier_filtering import filter_price_outliers @@ -56,6 +56,5 @@ __all__ = [ "ThresholdConfig", "calculate_periods", "calculate_periods_with_relaxation", - "filter_periods_by_volatility", "filter_price_outliers", ] diff --git a/custom_components/tibber_prices/period_utils/core.py b/custom_components/tibber_prices/period_utils/core.py index cd3d245..036fa61 100644 --- a/custom_components/tibber_prices/period_utils/core.py +++ b/custom_components/tibber_prices/period_utils/core.py @@ -163,88 +163,3 @@ def calculate_periods( "avg_prices": {k.isoformat(): v for k, v in avg_price_by_day.items()}, }, } - - -def filter_periods_by_volatility( - periods_data: dict[str, Any], - min_volatility: str, -) -> dict[str, Any]: - """ - Filter calculated periods based on their internal volatility. - - This applies period-level volatility filtering AFTER periods have been calculated. - Removes periods that don't meet the minimum volatility requirement based on their - own price spread (volatility attribute), not the daily volatility. - - Args: - periods_data: Dict with "periods" and "intervals" lists from calculate_periods_with_relaxation() - min_volatility: Minimum volatility level required ("low", "moderate", "high", "very_high") - - Returns: - Filtered periods_data dict with updated periods, intervals, and metadata. - - """ - periods = periods_data.get("periods", []) - if not periods: - return periods_data - - # "low" means no filtering (accept any volatility level) - if min_volatility == "low": - return periods_data - - # Define volatility hierarchy (LOW < MODERATE < HIGH < VERY_HIGH) - volatility_levels = ["LOW", "MODERATE", "HIGH", "VERY_HIGH"] - - # Map filter config values to actual level names - config_to_level = { - "low": "LOW", - "moderate": "MODERATE", - "high": "HIGH", - "very_high": "VERY_HIGH", - } - - min_level = config_to_level.get(min_volatility, "LOW") - - # Filter periods based on their volatility - filtered_periods = [] - for period in periods: - period_volatility = period.get("volatility", "MODERATE") - - # Check if period's volatility meets or exceeds minimum requirement - try: - period_idx = volatility_levels.index(period_volatility) - min_idx = volatility_levels.index(min_level) - except ValueError: - # If level not found, don't filter out this period - filtered_periods.append(period) - else: - if period_idx >= min_idx: - filtered_periods.append(period) - - # If no periods left after filtering, return empty structure - if not filtered_periods: - return { - "periods": [], - "intervals": [], - "metadata": { - "total_intervals": 0, - "total_periods": 0, - "config": periods_data.get("metadata", {}).get("config", {}), - }, - } - - # Collect intervals from filtered periods - filtered_intervals = [] - for period in filtered_periods: - filtered_intervals.extend(period.get("intervals", [])) - - # Update metadata - return { - "periods": filtered_periods, - "intervals": filtered_intervals, - "metadata": { - "total_intervals": len(filtered_intervals), - "total_periods": len(filtered_periods), - "config": periods_data.get("metadata", {}).get("config", {}), - }, - } diff --git a/custom_components/tibber_prices/period_utils/period_statistics.py b/custom_components/tibber_prices/period_utils/period_statistics.py index 6cbc13d..70f0750 100644 --- a/custom_components/tibber_prices/period_utils/period_statistics.py +++ b/custom_components/tibber_prices/period_utils/period_statistics.py @@ -185,7 +185,7 @@ def extract_period_summaries( Returns sensor-ready period summaries with: - Timestamps and positioning (start, end, hour, minute, time) - Aggregated price statistics (price_avg, price_min, price_max, price_spread) - - Volatility categorization (low/moderate/high/very_high based on absolute spread) + - Volatility categorization (low/moderate/high/very_high based on coefficient of variation) - Rating difference percentage (aggregated from intervals) - Period price differences (period_price_diff_from_daily_min/max) - Aggregated level and rating_level @@ -264,9 +264,12 @@ def extract_period_summaries( price_stats["price_avg"], start_time, price_context ) + # Extract prices for volatility calculation (coefficient of variation) + prices_for_volatility = [float(p["total"]) for p in period_price_data if "total" in p] + # Calculate volatility (categorical) and aggregated rating difference (numeric) volatility = calculate_volatility_level( - price_stats["price_spread"], + prices_for_volatility, threshold_moderate=thresholds.threshold_volatility_moderate, threshold_high=thresholds.threshold_volatility_high, threshold_very_high=thresholds.threshold_volatility_very_high, diff --git a/custom_components/tibber_prices/period_utils/relaxation.py b/custom_components/tibber_prices/period_utils/relaxation.py index 60f7516..fb837a8 100644 --- a/custom_components/tibber_prices/period_utils/relaxation.py +++ b/custom_components/tibber_prices/period_utils/relaxation.py @@ -167,7 +167,7 @@ def calculate_periods_with_relaxation( # noqa: PLR0913, PLR0915 - Per-day relax min_periods: int, relaxation_step_pct: int, max_relaxation_attempts: int, - should_show_callback: Callable[[str | None, str | None], bool], + should_show_callback: Callable[[str | None], bool], ) -> tuple[dict[str, Any], dict[str, Any]]: """ Calculate periods with optional per-day filter relaxation. @@ -179,8 +179,7 @@ def calculate_periods_with_relaxation( # noqa: PLR0913, PLR0915 - Per-day relax relaxes filters in multiple phases FOR EACH DAY SEPARATELY: Phase 1: Increase flex threshold step-by-step (up to 4 attempts) - Phase 2: Disable volatility filter (set to "any") - Phase 3: Disable level filter (set to "any") + Phase 2: Disable level filter (set to "any") Args: all_prices: All price data points @@ -191,7 +190,7 @@ def calculate_periods_with_relaxation( # noqa: PLR0913, PLR0915 - Per-day relax step (controls how aggressively flex widens with each attempt) max_relaxation_attempts: Maximum number of flex levels (attempts) to try per day before giving up (each attempt runs the full filter matrix) - should_show_callback: Callback function(volatility_override, level_override) -> bool + should_show_callback: Callback function(level_override) -> bool Returns True if periods should be shown with given filter overrides. Pass None to use original configured filter values. @@ -388,7 +387,7 @@ def relax_single_day( # noqa: PLR0913 - Comprehensive filter relaxation per day min_periods: int, relaxation_step_pct: int, max_relaxation_attempts: int, - should_show_callback: Callable[[str | None, str | None], bool], + should_show_callback: Callable[[str | None], bool], baseline_periods: list[dict], day_label: str, ) -> tuple[dict[str, Any], dict[str, Any]]: @@ -399,14 +398,11 @@ def relax_single_day( # noqa: PLR0913 - Comprehensive filter relaxation per day This finds solutions faster by relaxing filters first (cheaper than increasing flex). Per flex level (6.25%, 7.5%, 8.75%, 10%), try in order: - 1. Original filters (volatility=configured, level=configured) - 2. Relax only volatility (volatility=any, level=configured) - 3. Relax only level (volatility=configured, level=any) - 4. Relax both (volatility=any, level=any) + 1. Original filters (level=configured) + 2. Relax level filter (level=any) This ensures we find the minimal relaxation needed. Example: - If periods exist at flex=6.25% with level=any, we find them before trying flex=7.5% - - If periods need both filters relaxed, we try that before increasing flex further Args: day_prices: Price data for this specific day only @@ -414,7 +410,7 @@ def relax_single_day( # noqa: PLR0913 - Comprehensive filter relaxation per day min_periods: Minimum periods needed for this day relaxation_step_pct: Relaxation increment percentage max_relaxation_attempts: Maximum number of flex levels (attempts) to try for this day - should_show_callback: Filter visibility callback(volatility_override, level_override) + should_show_callback: Filter visibility callback(level_override) Returns True if periods should be shown with given overrides. baseline_periods: Periods found with normal filters day_label: Label for logging (e.g., "2025-11-11") @@ -436,7 +432,7 @@ def relax_single_day( # noqa: PLR0913 - Comprehensive filter relaxation per day baseline_standalone = len([p for p in baseline_periods if not p.get("is_extension")]) - attempts = max(1, max_relaxation_attempts) + attempts = max(1, int(max_relaxation_attempts)) # Flex levels: original + N steps (e.g., 5% → 6.25% → ...) for flex_step in range(1, attempts + 1): @@ -447,17 +443,15 @@ def relax_single_day( # noqa: PLR0913 - Comprehensive filter relaxation per day new_flex = -new_flex # Try filter combinations for this flex level - # Each tuple contains: volatility_override, level_override, label_suffix + # Each tuple contains: level_override, label_suffix filter_attempts = [ - (None, None, ""), # Original config - ("any", None, "+volatility_any"), # Relax volatility only - (None, "any", "+level_any"), # Relax level only - ("any", "any", "+all_filters_any"), # Relax both + (None, ""), # Original config + ("any", "+level_any"), # Relax level filter ] - for vol_override, lvl_override, label_suffix in filter_attempts: + for lvl_override, label_suffix in filter_attempts: # Check if this combination is allowed by user config - if not should_show_callback(vol_override, lvl_override): + if not should_show_callback(lvl_override): continue # Calculate periods with this flex + filter combination diff --git a/custom_components/tibber_prices/price_utils.py b/custom_components/tibber_prices/price_utils.py index 8e78a29..8271d50 100644 --- a/custom_components/tibber_prices/price_utils.py +++ b/custom_components/tibber_prices/price_utils.py @@ -3,6 +3,7 @@ from __future__ import annotations import logging +import statistics from datetime import datetime, timedelta from typing import Any @@ -24,22 +25,25 @@ from .const import ( _LOGGER = logging.getLogger(__name__) MINUTES_PER_INTERVAL = 15 +MIN_PRICES_FOR_VOLATILITY = 2 # Minimum number of price values needed for volatility calculation def calculate_volatility_level( - spread: float, + prices: list[float], threshold_moderate: float | None = None, threshold_high: float | None = None, threshold_very_high: float | None = None, ) -> str: """ - Calculate volatility level from price spread. + Calculate volatility level from price list using coefficient of variation. Volatility indicates how much prices fluctuate during a period, which helps - determine whether active load shifting is worthwhile. + determine whether active load shifting is worthwhile. Uses the coefficient + of variation (CV = std_dev / mean * 100%) for relative comparison that works + across different price levels and period lengths. Args: - spread: Absolute price difference between max and min (in minor currency units, e.g., ct or øre) + prices: List of price values (in any unit, typically major currency units like EUR or NOK) threshold_moderate: Custom threshold for MODERATE level (default: use DEFAULT_VOLATILITY_THRESHOLD_MODERATE) threshold_high: Custom threshold for HIGH level (default: use DEFAULT_VOLATILITY_THRESHOLD_HIGH) threshold_very_high: Custom threshold for VERY_HIGH level (default: use DEFAULT_VOLATILITY_THRESHOLD_VERY_HIGH) @@ -48,22 +52,40 @@ def calculate_volatility_level( Volatility level: "LOW", "MODERATE", "HIGH", or "VERY_HIGH" (uppercase) Examples: - - spread < 5: LOW → minimal optimization potential - - 5 ≤ spread < 15: MODERATE → some optimization worthwhile - - 15 ≤ spread < 30: HIGH → strong optimization recommended - - spread ≥ 30: VERY_HIGH → maximum optimization potential + - CV < 15%: LOW → minimal optimization potential, prices relatively stable + - 15% ≤ CV < 30%: MODERATE → some optimization worthwhile, noticeable variation + - 30% ≤ CV < 50%: HIGH → strong optimization recommended, significant swings + - CV ≥ 50%: VERY_HIGH → maximum optimization potential, extreme volatility + + Note: + Requires at least 2 price values for calculation. Returns LOW if insufficient data. + Works identically for short periods (2-3 intervals) and long periods (96 intervals/day). """ + # Need at least 2 values for standard deviation + if len(prices) < MIN_PRICES_FOR_VOLATILITY: + return VOLATILITY_LOW + # Use provided thresholds or fall back to constants t_moderate = threshold_moderate if threshold_moderate is not None else DEFAULT_VOLATILITY_THRESHOLD_MODERATE t_high = threshold_high if threshold_high is not None else DEFAULT_VOLATILITY_THRESHOLD_HIGH t_very_high = threshold_very_high if threshold_very_high is not None else DEFAULT_VOLATILITY_THRESHOLD_VERY_HIGH - if spread < t_moderate: + # Calculate coefficient of variation + mean = statistics.mean(prices) + if mean <= 0: + # Avoid division by zero or negative mean (shouldn't happen with prices) return VOLATILITY_LOW - if spread < t_high: + + std_dev = statistics.stdev(prices) + coefficient_of_variation = (std_dev / mean) * 100 # As percentage + + # Classify based on thresholds + if coefficient_of_variation < t_moderate: + return VOLATILITY_LOW + if coefficient_of_variation < t_high: return VOLATILITY_MODERATE - if spread < t_very_high: + if coefficient_of_variation < t_very_high: return VOLATILITY_HIGH return VOLATILITY_VERY_HIGH diff --git a/custom_components/tibber_prices/sensor.py b/custom_components/tibber_prices/sensor.py index 0e2e003..9f648e7 100644 --- a/custom_components/tibber_prices/sensor.py +++ b/custom_components/tibber_prices/sensor.py @@ -262,7 +262,7 @@ STATISTICS_SENSORS = ( ), ) -# Volatility sensors (price spread analysis) +# Volatility sensors (coefficient of variation analysis) # NOTE: Enum options are defined inline (not imported from const.py) to avoid # import timing issues with Home Assistant's entity platform initialization. # Keep in sync with VOLATILITY_OPTIONS in const.py! @@ -1459,15 +1459,15 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity): tomorrow_prices = [float(p["total"]) for p in price_info.get("tomorrow", []) if "total" in p] if today_prices: + today_vol = calculate_volatility_level(today_prices, **thresholds) today_spread = (max(today_prices) - min(today_prices)) * 100 - today_vol = calculate_volatility_level(today_spread, **thresholds) self._last_volatility_attributes["today_spread"] = round(today_spread, 2) self._last_volatility_attributes["today_volatility"] = today_vol self._last_volatility_attributes["interval_count_today"] = len(today_prices) if tomorrow_prices: + tomorrow_vol = calculate_volatility_level(tomorrow_prices, **thresholds) tomorrow_spread = (max(tomorrow_prices) - min(tomorrow_prices)) * 100 - tomorrow_vol = calculate_volatility_level(tomorrow_spread, **thresholds) self._last_volatility_attributes["tomorrow_spread"] = round(tomorrow_spread, 2) self._last_volatility_attributes["tomorrow_volatility"] = tomorrow_vol self._last_volatility_attributes["interval_count_tomorrow"] = len(tomorrow_prices) @@ -1479,7 +1479,7 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity): def _get_volatility_value(self, *, volatility_type: str) -> str | None: """ - Calculate price volatility (spread) for different time periods. + Calculate price volatility using coefficient of variation for different time periods. Args: volatility_type: One of "today", "tomorrow", "next_24h", "today_tomorrow" @@ -1506,16 +1506,17 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity): if not prices_to_analyze: return None - # Calculate spread + # Calculate spread and basic statistics price_min = min(prices_to_analyze) price_max = max(prices_to_analyze) spread = price_max - price_min + price_avg = sum(prices_to_analyze) / len(prices_to_analyze) - # Convert to minor currency units (ct/øre) for volatility calculation + # Convert to minor currency units (ct/øre) for display spread_minor = spread * 100 - # Calculate volatility level with custom thresholds - volatility = calculate_volatility_level(spread_minor, **thresholds) + # Calculate volatility level with custom thresholds (pass price list, not spread) + volatility = calculate_volatility_level(prices_to_analyze, **thresholds) # Store attributes for this sensor self._last_volatility_attributes = { @@ -1523,7 +1524,7 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity): "price_volatility": volatility, "price_min": round(price_min * 100, 2), "price_max": round(price_max * 100, 2), - "price_avg": round((sum(prices_to_analyze) / len(prices_to_analyze)) * 100, 2), + "price_avg": round(price_avg * 100, 2), "interval_count": len(prices_to_analyze), } diff --git a/custom_components/tibber_prices/services.py b/custom_components/tibber_prices/services.py index b00cd56..adedd4a 100644 --- a/custom_components/tibber_prices/services.py +++ b/custom_components/tibber_prices/services.py @@ -452,16 +452,16 @@ def _enrich_intervals_with_averages(intervals: list[dict], price_info_by_day: di def _get_price_stats(merged: list[dict], thresholds: dict) -> PriceStats: """Calculate average, min, and max price from merged data.""" if merged: - price_sum = sum(float(interval.get("price", 0)) for interval in merged if "price" in interval) - price_avg = round(price_sum / len(merged), 4) + prices = [float(interval.get("price", 0)) for interval in merged if "price" in interval] + price_avg = round(sum(prices) / len(prices), 4) if prices else 0 else: + prices = [] price_avg = 0 price_min, price_min_interval = _get_price_stat(merged, "min") price_max, price_max_interval = _get_price_stat(merged, "max") price_spread = round(price_max - price_min, 4) if price_min is not None and price_max is not None else 0 - # Convert spread to minor currency units (ct/øre) for volatility calculation - price_spread_minor = price_spread * 100 - price_volatility = calculate_volatility_level(price_spread_minor, **thresholds) + # Calculate volatility from price list (coefficient of variation) + price_volatility = calculate_volatility_level(prices, **thresholds) if prices else "low" return PriceStats( price_avg=price_avg, price_min=price_min, diff --git a/custom_components/tibber_prices/translations/de.json b/custom_components/tibber_prices/translations/de.json index ffd4262..8c6b1f4 100644 --- a/custom_components/tibber_prices/translations/de.json +++ b/custom_components/tibber_prices/translations/de.json @@ -156,11 +156,11 @@ }, "volatility": { "title": "Preisvolatilität Schwellenwerte", - "description": "{step_progress}\n\nKonfiguriere Schwellenwerte für die Volatilitätsklassifizierung. Volatilität misst Preisschwankungen (Spanne zwischen Min/Max) in kleinster Währungseinheit. Diese Schwellenwerte werden von Volatilitätssensoren und Periodenfiltern verwendet.", + "description": "{step_progress}\n\nKonfiguriere Schwellenwerte für die Volatilitätsklassifizierung. Volatilität misst relative Preisschwankungen anhand des Variationskoeffizienten (VK = Standardabweichung / Durchschnitt × 100%). Diese Schwellenwerte sind Prozentwerte, die für alle Preisniveaus funktionieren und von Volatilitätssensoren sowie Periodenfiltern verwendet werden.", "data": { - "volatility_threshold_moderate": "Moderate Schwelle (Spanne ≥ dieser Wert)", - "volatility_threshold_high": "Hohe Schwelle (Spanne ≥ dieser Wert)", - "volatility_threshold_very_high": "Sehr hohe Schwelle (Spanne ≥ dieser Wert)" + "volatility_threshold_moderate": "Moderate Schwelle (VK ≥ dieser %, z.B. 15)", + "volatility_threshold_high": "Hohe Schwelle (VK ≥ dieser %, z.B. 30)", + "volatility_threshold_very_high": "Sehr hohe Schwelle (VK ≥ dieser %, z.B. 50)" }, "submit": "Weiter zu Schritt 4" } diff --git a/custom_components/tibber_prices/translations/en.json b/custom_components/tibber_prices/translations/en.json index 2a09488..41c9a36 100644 --- a/custom_components/tibber_prices/translations/en.json +++ b/custom_components/tibber_prices/translations/en.json @@ -156,11 +156,11 @@ }, "volatility": { "title": "Price Volatility Thresholds", - "description": "{step_progress}\n\nConfigure thresholds for volatility classification. Volatility measures price variation (spread between min/max) in minor currency units. These thresholds are used by volatility sensors and period filters.", + "description": "{step_progress}\n\nConfigure thresholds for volatility classification. Volatility measures relative price variation using the coefficient of variation (CV = standard deviation / mean × 100%). These thresholds are percentage values that work across all price levels and are used by volatility sensors and period filters.", "data": { - "volatility_threshold_moderate": "Moderate Threshold (spread ≥ this value)", - "volatility_threshold_high": "High Threshold (spread ≥ this value)", - "volatility_threshold_very_high": "Very High Threshold (spread ≥ this value)" + "volatility_threshold_moderate": "Moderate Threshold (CV ≥ this %, e.g., 15)", + "volatility_threshold_high": "High Threshold (CV ≥ this %, e.g., 30)", + "volatility_threshold_very_high": "Very High Threshold (CV ≥ this %, e.g., 50)" }, "submit": "Next to Step 4" } diff --git a/custom_components/tibber_prices/translations/nb.json b/custom_components/tibber_prices/translations/nb.json index 1156d65..64557ec 100644 --- a/custom_components/tibber_prices/translations/nb.json +++ b/custom_components/tibber_prices/translations/nb.json @@ -156,11 +156,11 @@ }, "volatility": { "title": "Prisvolatilitet Terskler", - "description": "{step_progress}\n\nKonfigurer terskler for volatilitetsklassifisering. Volatilitet måler prisvariasjoner (spredning mellom min/maks) i minste valutaenhet (ct/øre). Disse tersklene brukes av volatilitetssensorer og periodefiltre.", + "description": "{step_progress}\n\nKonfigurer terskler for volatilitetsklassifisering. Volatilitet måler relative prisvariasjoner ved hjelp av variasjonskoeffisienten (VK = standardavvik / gjennomsnitt × 100%). Disse tersklene er prosentverdier som fungerer på alle prisnivåer og brukes av volatilitetssensorer og periodefiltre.", "data": { - "volatility_threshold_moderate": "Moderat terskel (ct/øre, spredning ≥ denne verdien)", - "volatility_threshold_high": "Høy terskel (ct/øre, spredning ≥ denne verdien)", - "volatility_threshold_very_high": "Veldig høy terskel (ct/øre, spredning ≥ denne verdien)" + "volatility_threshold_moderate": "Moderat terskel (VK ≥ denne %, f.eks. 15)", + "volatility_threshold_high": "Høy terskel (VK ≥ denne %, f.eks. 30)", + "volatility_threshold_very_high": "Veldig høy terskel (VK ≥ denne %, f.eks. 50)" }, "submit": "Neste til steg 4" } diff --git a/custom_components/tibber_prices/translations/nl.json b/custom_components/tibber_prices/translations/nl.json index 7c202b2..71ca151 100644 --- a/custom_components/tibber_prices/translations/nl.json +++ b/custom_components/tibber_prices/translations/nl.json @@ -156,11 +156,11 @@ }, "volatility": { "title": "Prijsvolatiliteit Drempels", - "description": "{step_progress}\n\nConfigureer drempels voor volatiliteitsclassificatie. Volatiliteit meet prijsschommelingen (spreiding tussen min/max) in kleinste valuta-eenheid (ct/øre). Deze drempels worden gebruikt door volatiliteitssensoren en periodefilters.", + "description": "{step_progress}\n\nConfigureer drempels voor volatiliteitsclassificatie. Volatiliteit meet relatieve prijsschommelingen aan de hand van de variatiecoëfficiënt (VC = standaarddeviatie / gemiddelde × 100%). Deze drempels zijn percentagewaarden die werken op alle prijsniveaus en worden gebruikt door volatiliteitssensoren en periodefilters.", "data": { - "volatility_threshold_moderate": "Matige drempel (ct/øre, spreiding ≥ deze waarde)", - "volatility_threshold_high": "Hoge drempel (ct/øre, spreiding ≥ deze waarde)", - "volatility_threshold_very_high": "Zeer hoge drempel (ct/øre, spreiding ≥ deze waarde)" + "volatility_threshold_moderate": "Matige drempel (VC ≥ deze %, bijv. 15)", + "volatility_threshold_high": "Hoge drempel (VC ≥ deze %, bijv. 30)", + "volatility_threshold_very_high": "Zeer hoge drempel (VC ≥ deze %, bijv. 50)" }, "submit": "Volgende naar stap 4" } diff --git a/custom_components/tibber_prices/translations/sv.json b/custom_components/tibber_prices/translations/sv.json index e93d96e..3f5ec21 100644 --- a/custom_components/tibber_prices/translations/sv.json +++ b/custom_components/tibber_prices/translations/sv.json @@ -156,11 +156,11 @@ }, "volatility": { "title": "Prisvolatilitet Trösklar", - "description": "{step_progress}\n\nKonfigurera trösklar för volatilitetsklassificering. Volatilitet mäter prisvariationer (spridning mellan min/max) i minsta valutaenhet (ct/øre). Dessa trösklar används av volatilitetssensorer och periodfilter.", + "description": "{step_progress}\n\nKonfigurera trösklar för volatilitetsklassificering. Volatilitet mäter relativa prisvariationer med hjälp av variationskoefficienten (VK = standardavvikelse / medelvärde × 100%). Dessa trösklar är procentvärden som fungerar på alla prisnivåer och används av volatilitetssensorer och periodfilter.", "data": { - "volatility_threshold_moderate": "Måttlig tröskel (ct/øre, spridning ≥ detta värde)", - "volatility_threshold_high": "Hög tröskel (ct/øre, spridning ≥ detta värde)", - "volatility_threshold_very_high": "Mycket hög tröskel (ct/øre, spridning ≥ detta värde)" + "volatility_threshold_moderate": "Måttlig tröskel (VK ≥ denna %, t.ex. 15)", + "volatility_threshold_high": "Hög tröskel (VK ≥ denna %, t.ex. 30)", + "volatility_threshold_very_high": "Mycket hög tröskel (VK ≥ detta %, t.ex. 50)" }, "submit": "Nästa till steg 4" } diff --git a/docs/user/period-calculation.md b/docs/user/period-calculation.md index 9200f54..bc508c7 100644 --- a/docs/user/period-calculation.md +++ b/docs/user/period-calculation.md @@ -110,7 +110,6 @@ Default: 60 minutes minimum You can optionally require: -- **Stable prices** (volatility filter) - "Only show if price doesn't fluctuate much" - **Absolute quality** (level filter) - "Only show if prices are CHEAP/EXPENSIVE (not just below/above average)" #### 5. Statistical Outlier Filtering @@ -221,19 +220,6 @@ peak_price_min_distance_from_avg: 2 ### Optional Filters -#### Volatility Filter (Price Stability) - -**What:** Only show periods with stable prices (low fluctuation) -**Default:** `low` (disabled) -**Options:** `low` | `moderate` | `high` | `very_high` - -```yaml -best_price_min_volatility: low # Show all periods -best_price_min_volatility: moderate # Only show if price doesn't swing >5 ct -``` - -**Use case:** "I want predictable prices during the period" - #### Level Filter (Absolute Quality) **What:** Only show periods with CHEAP/EXPENSIVE intervals (not just below/above average) @@ -298,10 +284,8 @@ For each day, the system tries: **4 Filter Combinations (per flexibility level):** -1. Original filters (your configured volatility + level) -2. Remove volatility filter (keep level filter) -3. Remove level filter (keep volatility filter) -4. Remove both filters +1. Original filters (your configured level filter) +2. Remove level filter **Example progression:** @@ -473,18 +457,17 @@ For advanced configuration patterns and technical deep-dive, see: **Configuration Parameters:** -| Parameter | Default | Range | Purpose | -| ---------------------------------- | ------- | ------------------ | ------------------------------ | -| `best_price_flex` | 15% | 0-100% | Search range from daily MIN | -| `best_price_min_period_length` | 60 min | 15-240 | Minimum duration | -| `best_price_min_distance_from_avg` | 2% | 0-20% | Quality threshold | -| `best_price_min_volatility` | low | low/mod/high/vhigh | Stability filter | -| `best_price_max_level` | any | any/cheap/vcheap | Absolute quality | -| `best_price_max_level_gap_count` | 0 | 0-10 | Gap tolerance | -| `enable_min_periods_best` | false | true/false | Enable relaxation | -| `min_periods_best` | - | 1-10 | Target periods per day | -| `relaxation_step_best` | - | 5-100% | Relaxation increment | -| `relaxation_attempts_best` | 8 | 1-12 | Flex levels (attempts) per day | +| Parameter | Default | Range | Purpose | +| ---------------------------------- | ------- | ---------------- | ------------------------------ | +| `best_price_flex` | 15% | 0-100% | Search range from daily MIN | +| `best_price_min_period_length` | 60 min | 15-240 | Minimum duration | +| `best_price_min_distance_from_avg` | 2% | 0-20% | Quality threshold | +| `best_price_max_level` | any | any/cheap/vcheap | Absolute quality | +| `best_price_max_level_gap_count` | 0 | 0-10 | Gap tolerance | +| `enable_min_periods_best` | false | true/false | Enable relaxation | +| `min_periods_best` | - | 1-10 | Target periods per day | +| `relaxation_step_best` | - | 5-100% | Relaxation increment | +| `relaxation_attempts_best` | 8 | 1-12 | Flex levels (attempts) per day | **Peak Price:** Same parameters with `peak_price_*` prefix (defaults: flex=-15%, same otherwise)