refactor(volatility): migrate to coefficient of variation calculation

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.
This commit is contained in:
Julian Pawlowski 2025-11-14 01:12:47 +00:00
parent 6dc49becb1
commit 07517660e3
16 changed files with 115 additions and 198 deletions

View file

@ -969,7 +969,7 @@ class TibberPricesOptionsFlowHandler(OptionsFlow):
min=0.0, min=0.0,
max=100.0, max=100.0,
step=0.1, step=0.1,
unit_of_measurement="ct", unit_of_measurement="%",
mode=NumberSelectorMode.BOX, mode=NumberSelectorMode.BOX,
), ),
), ),
@ -986,7 +986,7 @@ class TibberPricesOptionsFlowHandler(OptionsFlow):
min=0.0, min=0.0,
max=100.0, max=100.0,
step=0.1, step=0.1,
unit_of_measurement="ct", unit_of_measurement="%",
mode=NumberSelectorMode.BOX, mode=NumberSelectorMode.BOX,
), ),
), ),
@ -1003,7 +1003,7 @@ class TibberPricesOptionsFlowHandler(OptionsFlow):
min=0.0, min=0.0,
max=100.0, max=100.0,
step=0.1, step=0.1,
unit_of_measurement="ct", unit_of_measurement="%",
mode=NumberSelectorMode.BOX, mode=NumberSelectorMode.BOX,
), ),
), ),

View file

@ -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_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_RISING = 5 # Default trend threshold for rising prices (%)
DEFAULT_PRICE_TREND_THRESHOLD_FALLING = -5 # Default trend threshold for falling prices (%, negative value) 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 thresholds (relative values using coefficient of variation)
DEFAULT_VOLATILITY_THRESHOLD_HIGH = 15.0 # Default threshold for HIGH volatility (ct/øre) # Coefficient of variation = (standard_deviation / mean) * 100%
DEFAULT_VOLATILITY_THRESHOLD_VERY_HIGH = 30.0 # Default threshold for VERY_HIGH volatility (ct/øre) # 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_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_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_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_NORMAL = "NORMAL"
PRICE_RATING_HIGH = "HIGH" 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_LOW = "LOW"
VOLATILITY_MODERATE = "MODERATE" VOLATILITY_MODERATE = "MODERATE"
VOLATILITY_HIGH = "HIGH" VOLATILITY_HIGH = "HIGH"
@ -213,7 +216,7 @@ BEST_PRICE_MAX_LEVEL_OPTIONS = [
PRICE_LEVEL_EXPENSIVE.lower(), # Only show if level ≤ EXPENSIVE 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" # Sorted from expensive to cheap: user selects "starting from how expensive"
PEAK_PRICE_MIN_LEVEL_OPTIONS = [ PEAK_PRICE_MIN_LEVEL_OPTIONS = [
"any", # No filter, allow all price levels "any", # No filter, allow all price levels
@ -226,8 +229,8 @@ PEAK_PRICE_MIN_LEVEL_OPTIONS = [
# Relaxation level constants (for period filter relaxation) # Relaxation level constants (for period filter relaxation)
# These describe which filter relaxation was applied to find a period # These describe which filter relaxation was applied to find a period
RELAXATION_NONE = "none" # No relaxation, normal filters RELAXATION_NONE = "none" # No relaxation, normal filters
RELAXATION_VOLATILITY_ANY = "volatility_any" # Volatility filter disabled RELAXATION_LEVEL_ANY = "level_any" # Level filter disabled
RELAXATION_ALL_FILTERS_OFF = "all_filters_off" # All filters disabled (last resort) RELAXATION_ALL_FILTERS_OFF = "all_filters_off" # All filters disabled (deprecated, same as level_any)
# Mapping for comparing price levels (used for sorting) # Mapping for comparing price levels (used for sorting)
PRICE_LEVEL_MAPPING = { PRICE_LEVEL_MAPPING = {

View file

@ -762,9 +762,6 @@ class TibberPricesDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
""" """
Check if periods should be shown based on level filter only. 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: Args:
price_info: Price information dict with today/yesterday/tomorrow data price_info: Price information dict with today/yesterday/tomorrow data
reverse_sort: If False (best_price), checks max_level filter. 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, min_periods=min_periods_best,
relaxation_step_pct=relaxation_step_best, relaxation_step_pct=relaxation_step_best,
max_relaxation_attempts=relaxation_attempts_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, price_info,
reverse_sort=False, reverse_sort=False,
level_override=lvl, level_override=lvl,
@ -1279,7 +1276,7 @@ class TibberPricesDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
min_periods=min_periods_peak, min_periods=min_periods_peak,
relaxation_step_pct=relaxation_step_peak, relaxation_step_pct=relaxation_step_peak,
max_relaxation_attempts=relaxation_attempts_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, price_info,
reverse_sort=True, reverse_sort=True,
level_override=lvl, level_override=lvl,

View file

@ -17,7 +17,7 @@ All public APIs are re-exported for backwards compatibility.
from __future__ import annotations from __future__ import annotations
# Re-export main API functions # Re-export main API functions
from .core import calculate_periods, filter_periods_by_volatility from .core import calculate_periods
# Re-export outlier filtering # Re-export outlier filtering
from .outlier_filtering import filter_price_outliers from .outlier_filtering import filter_price_outliers
@ -56,6 +56,5 @@ __all__ = [
"ThresholdConfig", "ThresholdConfig",
"calculate_periods", "calculate_periods",
"calculate_periods_with_relaxation", "calculate_periods_with_relaxation",
"filter_periods_by_volatility",
"filter_price_outliers", "filter_price_outliers",
] ]

View file

@ -163,88 +163,3 @@ def calculate_periods(
"avg_prices": {k.isoformat(): v for k, v in avg_price_by_day.items()}, "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", {}),
},
}

View file

@ -185,7 +185,7 @@ def extract_period_summaries(
Returns sensor-ready period summaries with: Returns sensor-ready period summaries with:
- Timestamps and positioning (start, end, hour, minute, time) - Timestamps and positioning (start, end, hour, minute, time)
- Aggregated price statistics (price_avg, price_min, price_max, price_spread) - 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) - Rating difference percentage (aggregated from intervals)
- Period price differences (period_price_diff_from_daily_min/max) - Period price differences (period_price_diff_from_daily_min/max)
- Aggregated level and rating_level - Aggregated level and rating_level
@ -264,9 +264,12 @@ def extract_period_summaries(
price_stats["price_avg"], start_time, price_context 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) # Calculate volatility (categorical) and aggregated rating difference (numeric)
volatility = calculate_volatility_level( volatility = calculate_volatility_level(
price_stats["price_spread"], prices_for_volatility,
threshold_moderate=thresholds.threshold_volatility_moderate, threshold_moderate=thresholds.threshold_volatility_moderate,
threshold_high=thresholds.threshold_volatility_high, threshold_high=thresholds.threshold_volatility_high,
threshold_very_high=thresholds.threshold_volatility_very_high, threshold_very_high=thresholds.threshold_volatility_very_high,

View file

@ -167,7 +167,7 @@ def calculate_periods_with_relaxation( # noqa: PLR0913, PLR0915 - Per-day relax
min_periods: int, min_periods: int,
relaxation_step_pct: int, relaxation_step_pct: int,
max_relaxation_attempts: 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]]: ) -> tuple[dict[str, Any], dict[str, Any]]:
""" """
Calculate periods with optional per-day filter relaxation. 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: relaxes filters in multiple phases FOR EACH DAY SEPARATELY:
Phase 1: Increase flex threshold step-by-step (up to 4 attempts) Phase 1: Increase flex threshold step-by-step (up to 4 attempts)
Phase 2: Disable volatility filter (set to "any") Phase 2: Disable level filter (set to "any")
Phase 3: Disable level filter (set to "any")
Args: Args:
all_prices: All price data points 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) step (controls how aggressively flex widens with each attempt)
max_relaxation_attempts: Maximum number of flex levels (attempts) to try per day max_relaxation_attempts: Maximum number of flex levels (attempts) to try per day
before giving up (each attempt runs the full filter matrix) 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 Returns True if periods should be shown with given filter overrides. Pass None
to use original configured filter values. to use original configured filter values.
@ -388,7 +387,7 @@ def relax_single_day( # noqa: PLR0913 - Comprehensive filter relaxation per day
min_periods: int, min_periods: int,
relaxation_step_pct: int, relaxation_step_pct: int,
max_relaxation_attempts: 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], baseline_periods: list[dict],
day_label: str, day_label: str,
) -> tuple[dict[str, Any], dict[str, Any]]: ) -> 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). 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: Per flex level (6.25%, 7.5%, 8.75%, 10%), try in order:
1. Original filters (volatility=configured, level=configured) 1. Original filters (level=configured)
2. Relax only volatility (volatility=any, level=configured) 2. Relax level filter (level=any)
3. Relax only level (volatility=configured, level=any)
4. Relax both (volatility=any, level=any)
This ensures we find the minimal relaxation needed. Example: 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 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: Args:
day_prices: Price data for this specific day only 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 min_periods: Minimum periods needed for this day
relaxation_step_pct: Relaxation increment percentage relaxation_step_pct: Relaxation increment percentage
max_relaxation_attempts: Maximum number of flex levels (attempts) to try for this day 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. Returns True if periods should be shown with given overrides.
baseline_periods: Periods found with normal filters baseline_periods: Periods found with normal filters
day_label: Label for logging (e.g., "2025-11-11") 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")]) 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% → ...) # Flex levels: original + N steps (e.g., 5% → 6.25% → ...)
for flex_step in range(1, attempts + 1): 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 new_flex = -new_flex
# Try filter combinations for this flex level # 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 = [ filter_attempts = [
(None, None, ""), # Original config (None, ""), # Original config
("any", None, "+volatility_any"), # Relax volatility only ("any", "+level_any"), # Relax level filter
(None, "any", "+level_any"), # Relax level only
("any", "any", "+all_filters_any"), # Relax both
] ]
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 # 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 continue
# Calculate periods with this flex + filter combination # Calculate periods with this flex + filter combination

View file

@ -3,6 +3,7 @@
from __future__ import annotations from __future__ import annotations
import logging import logging
import statistics
from datetime import datetime, timedelta from datetime import datetime, timedelta
from typing import Any from typing import Any
@ -24,22 +25,25 @@ from .const import (
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
MINUTES_PER_INTERVAL = 15 MINUTES_PER_INTERVAL = 15
MIN_PRICES_FOR_VOLATILITY = 2 # Minimum number of price values needed for volatility calculation
def calculate_volatility_level( def calculate_volatility_level(
spread: float, prices: list[float],
threshold_moderate: float | None = None, threshold_moderate: float | None = None,
threshold_high: float | None = None, threshold_high: float | None = None,
threshold_very_high: float | None = None, threshold_very_high: float | None = None,
) -> str: ) -> 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 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: 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_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_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) 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) Volatility level: "LOW", "MODERATE", "HIGH", or "VERY_HIGH" (uppercase)
Examples: Examples:
- spread < 5: LOW minimal optimization potential - CV < 15%: LOW minimal optimization potential, prices relatively stable
- 5 spread < 15: MODERATE some optimization worthwhile - 15% CV < 30%: MODERATE some optimization worthwhile, noticeable variation
- 15 spread < 30: HIGH strong optimization recommended - 30% CV < 50%: HIGH strong optimization recommended, significant swings
- spread 30: VERY_HIGH maximum optimization potential - 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 # Use provided thresholds or fall back to constants
t_moderate = threshold_moderate if threshold_moderate is not None else DEFAULT_VOLATILITY_THRESHOLD_MODERATE 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_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 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 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 return VOLATILITY_MODERATE
if spread < t_very_high: if coefficient_of_variation < t_very_high:
return VOLATILITY_HIGH return VOLATILITY_HIGH
return VOLATILITY_VERY_HIGH return VOLATILITY_VERY_HIGH

View file

@ -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 # NOTE: Enum options are defined inline (not imported from const.py) to avoid
# import timing issues with Home Assistant's entity platform initialization. # import timing issues with Home Assistant's entity platform initialization.
# Keep in sync with VOLATILITY_OPTIONS in const.py! # 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] tomorrow_prices = [float(p["total"]) for p in price_info.get("tomorrow", []) if "total" in p]
if today_prices: if today_prices:
today_vol = calculate_volatility_level(today_prices, **thresholds)
today_spread = (max(today_prices) - min(today_prices)) * 100 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_spread"] = round(today_spread, 2)
self._last_volatility_attributes["today_volatility"] = today_vol self._last_volatility_attributes["today_volatility"] = today_vol
self._last_volatility_attributes["interval_count_today"] = len(today_prices) self._last_volatility_attributes["interval_count_today"] = len(today_prices)
if tomorrow_prices: if tomorrow_prices:
tomorrow_vol = calculate_volatility_level(tomorrow_prices, **thresholds)
tomorrow_spread = (max(tomorrow_prices) - min(tomorrow_prices)) * 100 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_spread"] = round(tomorrow_spread, 2)
self._last_volatility_attributes["tomorrow_volatility"] = tomorrow_vol self._last_volatility_attributes["tomorrow_volatility"] = tomorrow_vol
self._last_volatility_attributes["interval_count_tomorrow"] = len(tomorrow_prices) 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: 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: Args:
volatility_type: One of "today", "tomorrow", "next_24h", "today_tomorrow" volatility_type: One of "today", "tomorrow", "next_24h", "today_tomorrow"
@ -1506,16 +1506,17 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
if not prices_to_analyze: if not prices_to_analyze:
return None return None
# Calculate spread # Calculate spread and basic statistics
price_min = min(prices_to_analyze) price_min = min(prices_to_analyze)
price_max = max(prices_to_analyze) price_max = max(prices_to_analyze)
spread = price_max - price_min 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 spread_minor = spread * 100
# Calculate volatility level with custom thresholds # Calculate volatility level with custom thresholds (pass price list, not spread)
volatility = calculate_volatility_level(spread_minor, **thresholds) volatility = calculate_volatility_level(prices_to_analyze, **thresholds)
# Store attributes for this sensor # Store attributes for this sensor
self._last_volatility_attributes = { self._last_volatility_attributes = {
@ -1523,7 +1524,7 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
"price_volatility": volatility, "price_volatility": volatility,
"price_min": round(price_min * 100, 2), "price_min": round(price_min * 100, 2),
"price_max": round(price_max * 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), "interval_count": len(prices_to_analyze),
} }

View file

@ -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: def _get_price_stats(merged: list[dict], thresholds: dict) -> PriceStats:
"""Calculate average, min, and max price from merged data.""" """Calculate average, min, and max price from merged data."""
if merged: if merged:
price_sum = sum(float(interval.get("price", 0)) for interval in merged if "price" in interval) prices = [float(interval.get("price", 0)) for interval in merged if "price" in interval]
price_avg = round(price_sum / len(merged), 4) price_avg = round(sum(prices) / len(prices), 4) if prices else 0
else: else:
prices = []
price_avg = 0 price_avg = 0
price_min, price_min_interval = _get_price_stat(merged, "min") price_min, price_min_interval = _get_price_stat(merged, "min")
price_max, price_max_interval = _get_price_stat(merged, "max") 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 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 # Calculate volatility from price list (coefficient of variation)
price_spread_minor = price_spread * 100 price_volatility = calculate_volatility_level(prices, **thresholds) if prices else "low"
price_volatility = calculate_volatility_level(price_spread_minor, **thresholds)
return PriceStats( return PriceStats(
price_avg=price_avg, price_avg=price_avg,
price_min=price_min, price_min=price_min,

View file

@ -156,11 +156,11 @@
}, },
"volatility": { "volatility": {
"title": "Preisvolatilität Schwellenwerte", "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": { "data": {
"volatility_threshold_moderate": "Moderate Schwelle (Spanne ≥ dieser Wert)", "volatility_threshold_moderate": "Moderate Schwelle (VK ≥ dieser %, z.B. 15)",
"volatility_threshold_high": "Hohe Schwelle (Spanne ≥ dieser Wert)", "volatility_threshold_high": "Hohe Schwelle (VK ≥ dieser %, z.B. 30)",
"volatility_threshold_very_high": "Sehr hohe Schwelle (Spanne ≥ dieser Wert)" "volatility_threshold_very_high": "Sehr hohe Schwelle (VK ≥ dieser %, z.B. 50)"
}, },
"submit": "Weiter zu Schritt 4" "submit": "Weiter zu Schritt 4"
} }

View file

@ -156,11 +156,11 @@
}, },
"volatility": { "volatility": {
"title": "Price Volatility Thresholds", "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": { "data": {
"volatility_threshold_moderate": "Moderate Threshold (spread ≥ this value)", "volatility_threshold_moderate": "Moderate Threshold (CV ≥ this %, e.g., 15)",
"volatility_threshold_high": "High Threshold (spread ≥ this value)", "volatility_threshold_high": "High Threshold (CV ≥ this %, e.g., 30)",
"volatility_threshold_very_high": "Very High Threshold (spread ≥ this value)" "volatility_threshold_very_high": "Very High Threshold (CV ≥ this %, e.g., 50)"
}, },
"submit": "Next to Step 4" "submit": "Next to Step 4"
} }

View file

@ -156,11 +156,11 @@
}, },
"volatility": { "volatility": {
"title": "Prisvolatilitet Terskler", "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": { "data": {
"volatility_threshold_moderate": "Moderat terskel (ct/øre, spredning ≥ denne verdien)", "volatility_threshold_moderate": "Moderat terskel (VK ≥ denne %, f.eks. 15)",
"volatility_threshold_high": "Høy terskel (ct/øre, spredning ≥ denne verdien)", "volatility_threshold_high": "Høy terskel (VK ≥ denne %, f.eks. 30)",
"volatility_threshold_very_high": "Veldig høy terskel (ct/øre, spredning ≥ denne verdien)" "volatility_threshold_very_high": "Veldig høy terskel (VK ≥ denne %, f.eks. 50)"
}, },
"submit": "Neste til steg 4" "submit": "Neste til steg 4"
} }

View file

@ -156,11 +156,11 @@
}, },
"volatility": { "volatility": {
"title": "Prijsvolatiliteit Drempels", "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": { "data": {
"volatility_threshold_moderate": "Matige drempel (ct/øre, spreiding ≥ deze waarde)", "volatility_threshold_moderate": "Matige drempel (VC ≥ deze %, bijv. 15)",
"volatility_threshold_high": "Hoge drempel (ct/øre, spreiding ≥ deze waarde)", "volatility_threshold_high": "Hoge drempel (VC ≥ deze %, bijv. 30)",
"volatility_threshold_very_high": "Zeer hoge drempel (ct/øre, spreiding ≥ deze waarde)" "volatility_threshold_very_high": "Zeer hoge drempel (VC ≥ deze %, bijv. 50)"
}, },
"submit": "Volgende naar stap 4" "submit": "Volgende naar stap 4"
} }

View file

@ -156,11 +156,11 @@
}, },
"volatility": { "volatility": {
"title": "Prisvolatilitet Trösklar", "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": { "data": {
"volatility_threshold_moderate": "Måttlig 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 (ct/øre, spridning ≥ detta värde)", "volatility_threshold_high": "Hög tröskel (VK ≥ denna %, t.ex. 30)",
"volatility_threshold_very_high": "Mycket hög tröskel (ct/øre, spridning ≥ detta värde)" "volatility_threshold_very_high": "Mycket hög tröskel (VK ≥ detta %, t.ex. 50)"
}, },
"submit": "Nästa till steg 4" "submit": "Nästa till steg 4"
} }

View file

@ -110,7 +110,6 @@ Default: 60 minutes minimum
You can optionally require: 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)" - **Absolute quality** (level filter) - "Only show if prices are CHEAP/EXPENSIVE (not just below/above average)"
#### 5. Statistical Outlier Filtering #### 5. Statistical Outlier Filtering
@ -221,19 +220,6 @@ peak_price_min_distance_from_avg: 2
### Optional Filters ### 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) #### Level Filter (Absolute Quality)
**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)
@ -298,10 +284,8 @@ For each day, the system tries:
**4 Filter Combinations (per flexibility level):** **4 Filter Combinations (per flexibility level):**
1. Original filters (your configured volatility + level) 1. Original filters (your configured level filter)
2. Remove volatility filter (keep level filter) 2. Remove level filter
3. Remove level filter (keep volatility filter)
4. Remove both filters
**Example progression:** **Example progression:**
@ -473,18 +457,17 @@ For advanced configuration patterns and technical deep-dive, see:
**Configuration Parameters:** **Configuration Parameters:**
| Parameter | Default | Range | Purpose | | Parameter | Default | Range | Purpose |
| ---------------------------------- | ------- | ------------------ | ------------------------------ | | ---------------------------------- | ------- | ---------------- | ------------------------------ |
| `best_price_flex` | 15% | 0-100% | Search range from daily MIN | | `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_period_length` | 60 min | 15-240 | Minimum duration |
| `best_price_min_distance_from_avg` | 2% | 0-20% | Quality threshold | | `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` | any | any/cheap/vcheap | Absolute quality | | `best_price_max_level_gap_count` | 0 | 0-10 | Gap tolerance |
| `best_price_max_level_gap_count` | 0 | 0-10 | Gap tolerance | | `enable_min_periods_best` | false | true/false | Enable relaxation |
| `enable_min_periods_best` | false | true/false | Enable relaxation | | `min_periods_best` | - | 1-10 | Target periods per day |
| `min_periods_best` | - | 1-10 | Target periods per day | | `relaxation_step_best` | - | 5-100% | Relaxation increment |
| `relaxation_step_best` | - | 5-100% | Relaxation increment | | `relaxation_attempts_best` | 8 | 1-12 | Flex levels (attempts) per day |
| `relaxation_attempts_best` | 8 | 1-12 | Flex levels (attempts) per day |
**Peak Price:** Same parameters with `peak_price_*` prefix (defaults: flex=-15%, same otherwise) **Peak Price:** Same parameters with `peak_price_*` prefix (defaults: flex=-15%, same otherwise)