mirror of
https://github.com/jpawlowski/hass.tibber_prices.git
synced 2026-03-29 21:03:40 +00:00
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:
parent
6dc49becb1
commit
07517660e3
16 changed files with 115 additions and 198 deletions
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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", {}),
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue