refactor(config): optimize volatility thresholds with separate ranges and improved UX

Volatility Threshold Optimization:
- Replaced global MIN/MAX_VOLATILITY_THRESHOLD (0-100%) with six separate
  constants for overlapping ranges per threshold level
- MODERATE: 5.0-25.0% (was: 0-100%)
- HIGH: 20.0-40.0% (was: 0-100%)
- VERY_HIGH: 35.0-80.0% (was: 0-100%)
- Added detailed comments explaining ranges and cascading requirements

Validators:
- Added three specific validation functions (one per threshold level)
- Added cross-validation ensuring MODERATE < HIGH < VERY_HIGH
- Added fallback to existing option values for completeness check
- Updated error keys to specific messages per threshold level

UI Improvements:
- Changed NumberSelector mode: BOX → SLIDER (consistency with other config steps)
- Changed step size: 0.1% → 1.0% (better UX, sufficient precision)
- Updated min/max ranges to match new validation constants

Translations:
- Removed: "invalid_volatility_threshold" (generic)
- Added: "invalid_volatility_threshold_moderate/high/very_high" (specific ranges)
- Added: "invalid_volatility_thresholds" (cross-validation error)
- Updated all 5 languages (de, en, nb, nl, sv)

Files modified:
- config_flow_handlers/options_flow.py: Updated validation logic
- config_flow_handlers/schemas.py: Updated NumberSelector configs
- config_flow_handlers/validators.py: Added specific validators + cross-validation
- const.py: Replaced global constants with six specific constants
- translations/*.json: Updated error messages (5 languages)

Impact: Users get clearer validation errors with specific ranges shown,
better UX with sliders and appropriate step size, and guaranteed
threshold ordering (MODERATE < HIGH < VERY_HIGH).
This commit is contained in:
Julian Pawlowski 2025-11-21 17:31:07 +00:00
parent 0fd98554ae
commit 14b68a504b
9 changed files with 151 additions and 35 deletions

View file

@ -29,7 +29,10 @@ from custom_components.tibber_prices.config_flow_handlers.validators import (
validate_price_trend_falling,
validate_price_trend_rising,
validate_relaxation_attempts,
validate_volatility_threshold,
validate_volatility_threshold_high,
validate_volatility_threshold_moderate,
validate_volatility_threshold_very_high,
validate_volatility_thresholds,
)
from custom_components.tibber_prices.const import (
CONF_BEST_PRICE_FLEX,
@ -51,6 +54,9 @@ from custom_components.tibber_prices.const import (
CONF_VOLATILITY_THRESHOLD_HIGH,
CONF_VOLATILITY_THRESHOLD_MODERATE,
CONF_VOLATILITY_THRESHOLD_VERY_HIGH,
DEFAULT_VOLATILITY_THRESHOLD_HIGH,
DEFAULT_VOLATILITY_THRESHOLD_MODERATE,
DEFAULT_VOLATILITY_THRESHOLD_VERY_HIGH,
DOMAIN,
)
from homeassistant.config_entries import ConfigFlowResult, OptionsFlow
@ -359,22 +365,41 @@ class TibberPricesOptionsFlowHandler(OptionsFlow):
if user_input is not None:
# Validate moderate volatility threshold
if CONF_VOLATILITY_THRESHOLD_MODERATE in user_input and not validate_volatility_threshold(
if CONF_VOLATILITY_THRESHOLD_MODERATE in user_input and not validate_volatility_threshold_moderate(
user_input[CONF_VOLATILITY_THRESHOLD_MODERATE]
):
errors[CONF_VOLATILITY_THRESHOLD_MODERATE] = "invalid_volatility_threshold"
errors[CONF_VOLATILITY_THRESHOLD_MODERATE] = "invalid_volatility_threshold_moderate"
# Validate high volatility threshold
if CONF_VOLATILITY_THRESHOLD_HIGH in user_input and not validate_volatility_threshold(
if CONF_VOLATILITY_THRESHOLD_HIGH in user_input and not validate_volatility_threshold_high(
user_input[CONF_VOLATILITY_THRESHOLD_HIGH]
):
errors[CONF_VOLATILITY_THRESHOLD_HIGH] = "invalid_volatility_threshold"
errors[CONF_VOLATILITY_THRESHOLD_HIGH] = "invalid_volatility_threshold_high"
# Validate very high volatility threshold
if CONF_VOLATILITY_THRESHOLD_VERY_HIGH in user_input and not validate_volatility_threshold(
if CONF_VOLATILITY_THRESHOLD_VERY_HIGH in user_input and not validate_volatility_threshold_very_high(
user_input[CONF_VOLATILITY_THRESHOLD_VERY_HIGH]
):
errors[CONF_VOLATILITY_THRESHOLD_VERY_HIGH] = "invalid_volatility_threshold"
errors[CONF_VOLATILITY_THRESHOLD_VERY_HIGH] = "invalid_volatility_threshold_very_high"
# Cross-validation: Ensure MODERATE < HIGH < VERY_HIGH
if not errors:
existing_options = self.config_entry.options
moderate = user_input.get(
CONF_VOLATILITY_THRESHOLD_MODERATE,
existing_options.get(CONF_VOLATILITY_THRESHOLD_MODERATE, DEFAULT_VOLATILITY_THRESHOLD_MODERATE),
)
high = user_input.get(
CONF_VOLATILITY_THRESHOLD_HIGH,
existing_options.get(CONF_VOLATILITY_THRESHOLD_HIGH, DEFAULT_VOLATILITY_THRESHOLD_HIGH),
)
very_high = user_input.get(
CONF_VOLATILITY_THRESHOLD_VERY_HIGH,
existing_options.get(CONF_VOLATILITY_THRESHOLD_VERY_HIGH, DEFAULT_VOLATILITY_THRESHOLD_VERY_HIGH),
)
if not validate_volatility_thresholds(moderate, high, very_high):
errors["base"] = "invalid_volatility_thresholds"
if not errors:
self._options.update(user_input)

View file

@ -67,7 +67,9 @@ from custom_components.tibber_prices.const import (
MAX_PRICE_TREND_FALLING,
MAX_PRICE_TREND_RISING,
MAX_RELAXATION_ATTEMPTS,
MAX_VOLATILITY_THRESHOLD,
MAX_VOLATILITY_THRESHOLD_HIGH,
MAX_VOLATILITY_THRESHOLD_MODERATE,
MAX_VOLATILITY_THRESHOLD_VERY_HIGH,
MIN_GAP_COUNT,
MIN_PERIOD_LENGTH,
MIN_PRICE_RATING_THRESHOLD_HIGH,
@ -75,7 +77,9 @@ from custom_components.tibber_prices.const import (
MIN_PRICE_TREND_FALLING,
MIN_PRICE_TREND_RISING,
MIN_RELAXATION_ATTEMPTS,
MIN_VOLATILITY_THRESHOLD,
MIN_VOLATILITY_THRESHOLD_HIGH,
MIN_VOLATILITY_THRESHOLD_MODERATE,
MIN_VOLATILITY_THRESHOLD_VERY_HIGH,
PEAK_PRICE_MIN_LEVEL_OPTIONS,
)
from homeassistant.const import CONF_ACCESS_TOKEN
@ -215,11 +219,11 @@ def get_volatility_schema(options: Mapping[str, Any]) -> vol.Schema:
),
): NumberSelector(
NumberSelectorConfig(
min=MIN_VOLATILITY_THRESHOLD,
max=MAX_VOLATILITY_THRESHOLD,
step=0.1,
min=MIN_VOLATILITY_THRESHOLD_MODERATE,
max=MAX_VOLATILITY_THRESHOLD_MODERATE,
step=1.0,
unit_of_measurement="%",
mode=NumberSelectorMode.BOX,
mode=NumberSelectorMode.SLIDER,
),
),
vol.Optional(
@ -232,11 +236,11 @@ def get_volatility_schema(options: Mapping[str, Any]) -> vol.Schema:
),
): NumberSelector(
NumberSelectorConfig(
min=MIN_VOLATILITY_THRESHOLD,
max=MAX_VOLATILITY_THRESHOLD,
step=0.1,
min=MIN_VOLATILITY_THRESHOLD_HIGH,
max=MAX_VOLATILITY_THRESHOLD_HIGH,
step=1.0,
unit_of_measurement="%",
mode=NumberSelectorMode.BOX,
mode=NumberSelectorMode.SLIDER,
),
),
vol.Optional(
@ -249,11 +253,11 @@ def get_volatility_schema(options: Mapping[str, Any]) -> vol.Schema:
),
): NumberSelector(
NumberSelectorConfig(
min=MIN_VOLATILITY_THRESHOLD,
max=MAX_VOLATILITY_THRESHOLD,
step=0.1,
min=MIN_VOLATILITY_THRESHOLD_VERY_HIGH,
max=MAX_VOLATILITY_THRESHOLD_VERY_HIGH,
step=1.0,
unit_of_measurement="%",
mode=NumberSelectorMode.BOX,
mode=NumberSelectorMode.SLIDER,
),
),
}

View file

@ -21,7 +21,9 @@ from custom_components.tibber_prices.const import (
MAX_PRICE_TREND_FALLING,
MAX_PRICE_TREND_RISING,
MAX_RELAXATION_ATTEMPTS,
MAX_VOLATILITY_THRESHOLD,
MAX_VOLATILITY_THRESHOLD_HIGH,
MAX_VOLATILITY_THRESHOLD_MODERATE,
MAX_VOLATILITY_THRESHOLD_VERY_HIGH,
MIN_GAP_COUNT,
MIN_PERIOD_LENGTH,
MIN_PRICE_RATING_THRESHOLD_HIGH,
@ -29,7 +31,9 @@ from custom_components.tibber_prices.const import (
MIN_PRICE_TREND_FALLING,
MIN_PRICE_TREND_RISING,
MIN_RELAXATION_ATTEMPTS,
MIN_VOLATILITY_THRESHOLD,
MIN_VOLATILITY_THRESHOLD_HIGH,
MIN_VOLATILITY_THRESHOLD_MODERATE,
MIN_VOLATILITY_THRESHOLD_VERY_HIGH,
)
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.aiohttp_client import async_create_clientsession
@ -219,18 +223,78 @@ def validate_price_rating_thresholds(threshold_low: int, threshold_high: int) ->
return threshold_low < threshold_high
def validate_volatility_threshold(threshold: float) -> bool:
def validate_volatility_threshold_moderate(threshold: float) -> bool:
"""
Validate volatility threshold percentage.
Validate moderate volatility threshold.
Args:
threshold: Volatility threshold percentage (0.0 to 100.0)
threshold: Moderate volatility threshold percentage (5.0 to 25.0)
Returns:
True if threshold is valid (MIN_VOLATILITY_THRESHOLD to MAX_VOLATILITY_THRESHOLD)
True if threshold is valid (MIN_VOLATILITY_THRESHOLD_MODERATE to MAX_VOLATILITY_THRESHOLD_MODERATE)
"""
return MIN_VOLATILITY_THRESHOLD <= threshold <= MAX_VOLATILITY_THRESHOLD
return MIN_VOLATILITY_THRESHOLD_MODERATE <= threshold <= MAX_VOLATILITY_THRESHOLD_MODERATE
def validate_volatility_threshold_high(threshold: float) -> bool:
"""
Validate high volatility threshold.
Args:
threshold: High volatility threshold percentage (20.0 to 40.0)
Returns:
True if threshold is valid (MIN_VOLATILITY_THRESHOLD_HIGH to MAX_VOLATILITY_THRESHOLD_HIGH)
"""
return MIN_VOLATILITY_THRESHOLD_HIGH <= threshold <= MAX_VOLATILITY_THRESHOLD_HIGH
def validate_volatility_threshold_very_high(threshold: float) -> bool:
"""
Validate very high volatility threshold.
Args:
threshold: Very high volatility threshold percentage (35.0 to 80.0)
Returns:
True if threshold is valid (MIN_VOLATILITY_THRESHOLD_VERY_HIGH to MAX_VOLATILITY_THRESHOLD_VERY_HIGH)
"""
return MIN_VOLATILITY_THRESHOLD_VERY_HIGH <= threshold <= MAX_VOLATILITY_THRESHOLD_VERY_HIGH
def validate_volatility_thresholds(
threshold_moderate: float,
threshold_high: float,
threshold_very_high: float,
) -> bool:
"""
Cross-validate all three volatility thresholds together.
Ensures that MODERATE < HIGH < VERY_HIGH to maintain logical classification
boundaries. Each threshold represents an escalating level of price volatility.
Args:
threshold_moderate: Moderate volatility threshold (5.0 to 25.0)
threshold_high: High volatility threshold (20.0 to 40.0)
threshold_very_high: Very high volatility threshold (35.0 to 80.0)
Returns:
True if all thresholds are valid individually AND maintain proper ordering
"""
# Validate individual ranges first
if not validate_volatility_threshold_moderate(threshold_moderate):
return False
if not validate_volatility_threshold_high(threshold_high):
return False
if not validate_volatility_threshold_very_high(threshold_very_high):
return False
# Ensure cascading order: MODERATE < HIGH < VERY_HIGH
return threshold_moderate < threshold_high < threshold_very_high
def validate_price_trend_rising(threshold: int) -> bool:

View file

@ -112,8 +112,16 @@ MIN_PRICE_RATING_THRESHOLD_HIGH = 5 # Minimum value for high rating threshold (
MAX_PRICE_RATING_THRESHOLD_HIGH = 50 # Maximum value for high rating threshold
# Volatility threshold limits
MIN_VOLATILITY_THRESHOLD = 0.0 # Minimum volatility threshold percentage
MAX_VOLATILITY_THRESHOLD = 100.0 # Maximum volatility threshold percentage
# MODERATE threshold: practical range 5% to 25% (entry point for noticeable fluctuation)
# HIGH threshold: practical range 20% to 40% (significant price swings)
# VERY_HIGH threshold: practical range 35% to 80% (extreme volatility)
# Ensure cascading: MODERATE < HIGH < VERY_HIGH with ~5% minimum gaps
MIN_VOLATILITY_THRESHOLD_MODERATE = 5.0 # Minimum for moderate volatility threshold
MAX_VOLATILITY_THRESHOLD_MODERATE = 25.0 # Maximum for moderate volatility threshold (must be < HIGH)
MIN_VOLATILITY_THRESHOLD_HIGH = 20.0 # Minimum for high volatility threshold (must be > MODERATE)
MAX_VOLATILITY_THRESHOLD_HIGH = 40.0 # Maximum for high volatility threshold (must be < VERY_HIGH)
MIN_VOLATILITY_THRESHOLD_VERY_HIGH = 35.0 # Minimum for very high volatility threshold (must be > HIGH)
MAX_VOLATILITY_THRESHOLD_VERY_HIGH = 80.0 # Maximum for very high volatility threshold
# Price trend threshold limits
MIN_PRICE_TREND_RISING = 1 # Minimum rising trend threshold

View file

@ -207,7 +207,10 @@
"invalid_price_rating_low": "Untere Preis-Bewertungsschwelle muss zwischen -50% und -5% liegen",
"invalid_price_rating_high": "Obere Preis-Bewertungsschwelle muss zwischen 5% und 50% liegen",
"invalid_price_rating_thresholds": "Untere Schwelle muss kleiner als obere Schwelle sein",
"invalid_volatility_threshold": "Volatilitätsschwelle muss zwischen 0% und 100% liegen",
"invalid_volatility_threshold_moderate": "Moderate Volatilitätsschwelle muss zwischen 5% und 25% liegen",
"invalid_volatility_threshold_high": "Hohe Volatilitätsschwelle muss zwischen 20% und 40% liegen",
"invalid_volatility_threshold_very_high": "Sehr hohe Volatilitätsschwelle muss zwischen 35% und 80% liegen",
"invalid_volatility_thresholds": "Schwellenwerte müssen aufsteigend sein: moderat < hoch < sehr hoch",
"invalid_price_trend_rising": "Steigender Trendschwellenwert muss zwischen 1% und 50% liegen",
"invalid_price_trend_falling": "Fallender Trendschwellenwert muss zwischen -50% und -1% liegen"
},

View file

@ -207,7 +207,10 @@
"invalid_price_rating_low": "Low price rating threshold must be between -50% and -5%",
"invalid_price_rating_high": "High price rating threshold must be between 5% and 50%",
"invalid_price_rating_thresholds": "Low threshold must be less than high threshold",
"invalid_volatility_threshold": "Volatility threshold must be between 0% and 100%",
"invalid_volatility_threshold_moderate": "Moderate volatility threshold must be between 5% and 25%",
"invalid_volatility_threshold_high": "High volatility threshold must be between 20% and 40%",
"invalid_volatility_threshold_very_high": "Very high volatility threshold must be between 35% and 80%",
"invalid_volatility_thresholds": "Thresholds must be in ascending order: moderate < high < very high",
"invalid_price_trend_rising": "Rising trend threshold must be between 1% and 50%",
"invalid_price_trend_falling": "Falling trend threshold must be between -50% and -1%"
},

View file

@ -207,7 +207,10 @@
"invalid_price_rating_low": "Lav prisvurderingsgrense må være mellom -50% og -5%",
"invalid_price_rating_high": "Høy prisvurderingsgrense må være mellom 5% og 50%",
"invalid_price_rating_thresholds": "Lav grense må være mindre enn høy grense",
"invalid_volatility_threshold": "Volatilitetsgrense må være mellom 0% og 100%",
"invalid_volatility_threshold_moderate": "Moderat volatilitetsgrense må være mellom 5% og 25%",
"invalid_volatility_threshold_high": "Høy volatilitetsgrense må være mellom 20% og 40%",
"invalid_volatility_threshold_very_high": "Svært høy volatilitetsgrense må være mellom 35% og 80%",
"invalid_volatility_thresholds": "Grensene må være i stigende rekkefølge: moderat < høy < svært høy",
"invalid_price_trend_rising": "Stigende trendgrense må være mellom 1% og 50%",
"invalid_price_trend_falling": "Fallende trendgrense må være mellom -50% og -1%"
},

View file

@ -207,7 +207,10 @@
"invalid_price_rating_low": "Lage prijsbeoordelingsdrempel moet tussen -50% en -5% liggen",
"invalid_price_rating_high": "Hoge prijsbeoordelingsdrempel moet tussen 5% en 50% liggen",
"invalid_price_rating_thresholds": "Lage drempel moet lager zijn dan hoge drempel",
"invalid_volatility_threshold": "Volatiliteitsdrempel moet tussen 0% en 100% liggen",
"invalid_volatility_threshold_moderate": "Gematigde volatiliteitsdrempel moet tussen 5% en 25% liggen",
"invalid_volatility_threshold_high": "Hoge volatiliteitsdrempel moet tussen 20% en 40% liggen",
"invalid_volatility_threshold_very_high": "Zeer hoge volatiliteitsdrempel moet tussen 35% en 80% liggen",
"invalid_volatility_thresholds": "Drempels moeten in oplopende volgorde zijn: gematigd < hoog < zeer hoog",
"invalid_price_trend_rising": "Stijgende trenddrempel moet tussen 1% en 50% liggen",
"invalid_price_trend_falling": "Dalende trenddrempel moet tussen -50% en -1% liggen"
},

View file

@ -207,7 +207,10 @@
"invalid_price_rating_low": "Låg prisklassificeringströskel måste vara mellan -50% och -5%",
"invalid_price_rating_high": "Hög prisklassificeringströskel måste vara mellan 5% och 50%",
"invalid_price_rating_thresholds": "Låg tröskel måste vara mindre än hög tröskel",
"invalid_volatility_threshold": "Volatilitetströskel måste vara mellan 0% och 100%",
"invalid_volatility_threshold_moderate": "Måttlig volatilitetströskel måste vara mellan 5% och 25%",
"invalid_volatility_threshold_high": "Hög volatilitetströskel måste vara mellan 20% och 40%",
"invalid_volatility_threshold_very_high": "Mycket hög volatilitetströskel måste vara mellan 35% och 80%",
"invalid_volatility_thresholds": "Trösklar måste vara i stigande ordning: måttlig < hög < mycket hög",
"invalid_price_trend_rising": "Stigande trendtröskel måste vara mellan 1% och 50%",
"invalid_price_trend_falling": "Fallande trendtröskel måste vara mellan -50% och -1%"
},