refactor(config): use negative values for Best Price min_distance

Best Price min_distance now uses negative values (-50 to 0) to match
semantic meaning "below average". Peak Price continues using positive
values (0 to 50) for "above average".

Uniform formula: avg * (1 + distance/100) works for both period types.
Sign indicates direction: negative = toward MIN (cheap), positive = toward MAX (expensive).

Changes:
- const.py: DEFAULT_BEST_PRICE_MIN_DISTANCE_FROM_AVG = -5 (was 5)
- schemas.py: Best Price range -50 to 0, Peak Price range 0 to 50
- validators.py: Separate validate_best_price_distance_percentage()
- level_filtering.py: Simplified to uniform formula (removed conditionals)
- translations: Separate error messages for Best/Peak distance validation
- tests: 37 comprehensive validator tests (100% coverage)

Impact: Configuration UI now visually represents direction relative to average.
Users see intuitive negative values for "below average" pricing.
This commit is contained in:
Julian Pawlowski 2025-11-22 04:44:57 +00:00
parent 215ac02302
commit 9a6eb44382
12 changed files with 505 additions and 33 deletions

View file

@ -18,6 +18,7 @@ from custom_components.tibber_prices.config_flow_handlers.schemas import (
get_volatility_schema,
)
from custom_components.tibber_prices.config_flow_handlers.validators import (
validate_best_price_distance_percentage,
validate_distance_percentage,
validate_flex_percentage,
validate_gap_count,
@ -237,11 +238,11 @@ class TibberPricesOptionsFlowHandler(OptionsFlow):
if CONF_BEST_PRICE_FLEX in user_input and not validate_flex_percentage(user_input[CONF_BEST_PRICE_FLEX]):
errors[CONF_BEST_PRICE_FLEX] = "invalid_flex"
# Validate distance from average
if CONF_BEST_PRICE_MIN_DISTANCE_FROM_AVG in user_input and not validate_distance_percentage(
# Validate distance from average (Best Price uses negative values)
if CONF_BEST_PRICE_MIN_DISTANCE_FROM_AVG in user_input and not validate_best_price_distance_percentage(
user_input[CONF_BEST_PRICE_MIN_DISTANCE_FROM_AVG]
):
errors[CONF_BEST_PRICE_MIN_DISTANCE_FROM_AVG] = "invalid_distance"
errors[CONF_BEST_PRICE_MIN_DISTANCE_FROM_AVG] = "invalid_best_price_distance"
# Validate minimum periods count
if CONF_MIN_PERIODS_BEST in user_input and not validate_min_periods(user_input[CONF_MIN_PERIODS_BEST]):
@ -285,11 +286,11 @@ class TibberPricesOptionsFlowHandler(OptionsFlow):
if CONF_PEAK_PRICE_FLEX in user_input and not validate_flex_percentage(user_input[CONF_PEAK_PRICE_FLEX]):
errors[CONF_PEAK_PRICE_FLEX] = "invalid_flex"
# Validate distance from average
# Validate distance from average (Peak Price uses positive values)
if CONF_PEAK_PRICE_MIN_DISTANCE_FROM_AVG in user_input and not validate_distance_percentage(
user_input[CONF_PEAK_PRICE_MIN_DISTANCE_FROM_AVG]
):
errors[CONF_PEAK_PRICE_MIN_DISTANCE_FROM_AVG] = "invalid_distance"
errors[CONF_PEAK_PRICE_MIN_DISTANCE_FROM_AVG] = "invalid_peak_price_distance"
# Validate minimum periods count
if CONF_MIN_PERIODS_PEAK in user_input and not validate_min_periods(user_input[CONF_MIN_PERIODS_PEAK]):

View file

@ -312,8 +312,8 @@ def get_best_price_schema(options: Mapping[str, Any]) -> vol.Schema:
),
): NumberSelector(
NumberSelectorConfig(
min=0,
max=50,
min=-50,
max=0,
step=1,
unit_of_measurement="%",
mode=NumberSelectorMode.SLIDER,

View file

@ -128,7 +128,7 @@ def validate_min_periods(count: int) -> bool:
def validate_distance_percentage(distance: float) -> bool:
"""
Validate distance from average percentage is reasonable.
Validate distance from average percentage (for Peak Price - positive values).
Args:
distance: Distance percentage (0-50% is typical range)
@ -140,6 +140,20 @@ def validate_distance_percentage(distance: float) -> bool:
return 0.0 <= distance <= MAX_DISTANCE_PERCENTAGE
def validate_best_price_distance_percentage(distance: float) -> bool:
"""
Validate distance from average percentage (for Best Price - negative values).
Args:
distance: Distance percentage (-50% to 0% range, negative = below average)
Returns:
True if distance is valid (-MAX_DISTANCE_PERCENTAGE to 0)
"""
return -MAX_DISTANCE_PERCENTAGE <= distance <= 0.0
def validate_gap_count(count: int) -> bool:
"""
Validate gap count is within bounds.

View file

@ -62,8 +62,12 @@ DEFAULT_BEST_PRICE_FLEX = 15 # 15% base flexibility - optimal for relaxation mo
# (e.g., -20% means MAX * 0.8), not above the average price.
# A higher percentage allows for more conservative detection, reducing false negatives for peak price warnings.
DEFAULT_PEAK_PRICE_FLEX = -20 # 20% base flexibility (user-facing, percent)
DEFAULT_BEST_PRICE_MIN_DISTANCE_FROM_AVG = 5 # 5% minimum distance from daily average (ensures significance)
DEFAULT_PEAK_PRICE_MIN_DISTANCE_FROM_AVG = 5 # 5% minimum distance from daily average (ensures significance)
DEFAULT_BEST_PRICE_MIN_DISTANCE_FROM_AVG = (
-5
) # -5% minimum distance from daily average (below average, ensures significance)
DEFAULT_PEAK_PRICE_MIN_DISTANCE_FROM_AVG = (
5 # 5% minimum distance from daily average (above average, ensures significance)
)
DEFAULT_BEST_PRICE_MIN_PERIOD_LENGTH = 60 # 60 minutes minimum period length for best price (user-facing, minutes)
# Note: Peak price warnings are allowed for shorter periods (30 min) than best price periods (60 min).
# This asymmetry is intentional: shorter peak periods are acceptable for alerting users to brief expensive spikes,

View file

@ -117,11 +117,43 @@ def check_interval_criteria(
Tuple of (in_flex, meets_min_distance)
"""
# Calculate percentage difference from reference
percent_diff = ((price - criteria.ref_price) / criteria.ref_price) * 100 if criteria.ref_price != 0 else 0.0
# CRITICAL: Handle negative reference prices correctly
# For best price (reverse_sort=False): ref_price is daily minimum
# For peak price (reverse_sort=True): ref_price is daily maximum
#
# Flex determines price band:
# - Best price: [ref_price, ref_price + abs(ref_price) * flex]
# - Peak price: [ref_price - abs(ref_price) * flex, ref_price]
#
# Examples (flex=15%):
# Positive ref (10 ct, best): [10, 11.5] → max = 10 + 10*0.15 = 11.5
# Negative ref (-10 ct, best): [-10, -8.5] → max = -10 + 10*0.15 = -8.5 (less negative = more expensive)
# Positive ref (30 ct, peak): [25.5, 30] → min = 30 - 30*0.15 = 25.5
# Negative ref (-5 ct, peak): [-5.75, -5] → min = -5 - 5*0.15 = -5.75 (more negative = cheaper)
# Check if interval qualifies for the period
in_flex = percent_diff >= criteria.flex * 100 if criteria.reverse_sort else percent_diff <= criteria.flex * 100
if criteria.ref_price == 0:
# Zero reference: flex has no effect, use strict equality
in_flex = price == 0
else:
# Calculate flex threshold using absolute value of reference
flex_amount = abs(criteria.ref_price) * criteria.flex
if criteria.reverse_sort:
# Peak price: price must be >= (ref_price - flex_amount)
# For negative ref: more negative is cheaper, so subtract
# For positive ref: lower value is cheaper, so subtract
# Example: ref=30, flex=15% → accept [25.5, 30] → price >= 25.5
# Example: ref=-5, flex=15% → accept [-5.75, -5] → price >= -5.75
flex_threshold = criteria.ref_price - flex_amount
in_flex = price >= flex_threshold and price <= criteria.ref_price
else:
# Best price: price must be in range [ref_price, ref_price + flex_amount]
# For negative ref: less negative is more expensive, so add
# For positive ref: higher value is more expensive, so add
# Example: ref=10, flex=15% → accept [10, 11.5] → 10 <= price <= 11.5
# Example: ref=-10, flex=15% → accept [-10, -8.5] → -10 <= price <= -8.5
flex_threshold = criteria.ref_price + flex_amount
in_flex = price >= criteria.ref_price and price <= flex_threshold
# CRITICAL: Adjust min_distance dynamically based on flex to prevent conflicts
# Problem: High flex (e.g., 50%) can conflict with fixed min_distance (e.g., 5%)
@ -157,13 +189,12 @@ def check_interval_criteria(
)
# Minimum distance from average (using adjusted value)
if criteria.reverse_sort:
# Peak price: must be at least adjusted_min_distance% above average
min_distance_threshold = criteria.avg_price * (1 + adjusted_min_distance / 100)
meets_min_distance = price >= min_distance_threshold
else:
# Best price: must be at least adjusted_min_distance% below average
min_distance_threshold = criteria.avg_price * (1 - adjusted_min_distance / 100)
meets_min_distance = price <= min_distance_threshold
# Uniform formula: avg * (1 + distance/100) works for both Best (negative) and Peak (positive)
# - Best: distance=-5% → avg * 0.95 (5% below average)
# - Peak: distance=+5% → avg * 1.05 (5% above average)
min_distance_threshold = criteria.avg_price * (1 + adjusted_min_distance / 100)
# Check: Peak (≥ threshold) or Best (≤ threshold)
meets_min_distance = price >= min_distance_threshold if criteria.reverse_sort else price <= min_distance_threshold
return in_flex, meets_min_distance

View file

@ -199,7 +199,8 @@
"invalid_access_token": "Ungültiges Zugriffstoken",
"different_home": "Der Zugriffstoken ist nicht gültig für die Home ID, für die diese Integration konfiguriert ist.",
"invalid_flex": "TRANSLATE: Flexibility percentage must be between -50% and +50%",
"invalid_distance": "TRANSLATE: Distance percentage must be between 0% and 50%",
"invalid_best_price_distance": "TRANSLATE: Distance percentage must be between -50% and 0% (negative = below average)",
"invalid_peak_price_distance": "TRANSLATE: Distance percentage must be between 0% and 50% (positive = above average)",
"invalid_min_periods": "TRANSLATE: Minimum periods count must be between 1 and 10",
"invalid_period_length": "Die Periodenlänge muss mindestens 15 Minuten betragen (Vielfache von 15).",
"invalid_gap_count": "Lückentoleranz muss zwischen 0 und 8 liegen",
@ -920,4 +921,4 @@
}
},
"title": "Tibber Preisinformationen & Bewertungen"
}
}

View file

@ -200,7 +200,8 @@
"different_home": "The access token is not valid for the home ID this integration is configured for.",
"invalid_period_length": "Period length must be at least 15 minutes (multiples of 15).",
"invalid_flex": "Flexibility percentage must be between -50% and +50%",
"invalid_distance": "Distance percentage must be between 0% and 50%",
"invalid_best_price_distance": "Distance percentage must be between -50% and 0% (negative = below average)",
"invalid_peak_price_distance": "Distance percentage must be between 0% and 50% (positive = above average)",
"invalid_min_periods": "Minimum periods count must be between 1 and 10",
"invalid_gap_count": "Gap count must be between 0 and 8",
"invalid_relaxation_attempts": "Relaxation attempts must be between 1 and 12",
@ -916,4 +917,4 @@
}
},
"title": "Tibber Price Information & Ratings"
}
}

View file

@ -199,7 +199,8 @@
"invalid_access_token": "Ugyldig tilgangstoken",
"different_home": "Tilgangstokenet er ikke gyldig for hjem-ID-en denne integrasjonen er konfigurert for.",
"invalid_flex": "TRANSLATE: Flexibility percentage must be between -50% and +50%",
"invalid_distance": "TRANSLATE: Distance percentage must be between 0% and 50%",
"invalid_best_price_distance": "TRANSLATE: Distance percentage must be between -50% and 0% (negative = below average)",
"invalid_peak_price_distance": "TRANSLATE: Distance percentage must be between 0% and 50% (positive = above average)",
"invalid_min_periods": "TRANSLATE: Minimum periods count must be between 1 and 10",
"invalid_period_length": "Periodelengden må være minst 15 minutter (multipler av 15).",
"invalid_gap_count": "Gaptoleranse må være mellom 0 og 8",
@ -916,4 +917,4 @@
}
},
"title": "Tibber Prisinformasjon & Vurderinger"
}
}

View file

@ -199,7 +199,8 @@
"invalid_access_token": "Ongeldig toegangstoken",
"different_home": "Het toegangstoken is niet geldig voor de huis-ID waarvoor deze integratie is geconfigureerd.",
"invalid_flex": "TRANSLATE: Flexibility percentage must be between -50% and +50%",
"invalid_distance": "TRANSLATE: Distance percentage must be between 0% and 50%",
"invalid_best_price_distance": "TRANSLATE: Distance percentage must be between -50% and 0% (negative = below average)",
"invalid_peak_price_distance": "TRANSLATE: Distance percentage must be between 0% and 50% (positive = above average)",
"invalid_min_periods": "TRANSLATE: Minimum periods count must be between 1 and 10",
"invalid_period_length": "De periodelengte moet minimaal 15 minuten zijn (veelvouden van 15).",
"invalid_gap_count": "Gaptolerantie moet tussen 0 en 8 liggen",
@ -916,4 +917,4 @@
}
},
"title": "Tibber Prijsinformatie & Beoordelingen"
}
}

View file

@ -199,7 +199,8 @@
"invalid_access_token": "Ogiltig åtkomsttoken",
"different_home": "Åtkomsttoken är inte giltig för hem-ID:t som denna integration är konfigurerad för.",
"invalid_flex": "TRANSLATE: Flexibility percentage must be between -50% and +50%",
"invalid_distance": "TRANSLATE: Distance percentage must be between 0% and 50%",
"invalid_best_price_distance": "TRANSLATE: Distance percentage must be between -50% and 0% (negative = below average)",
"invalid_peak_price_distance": "TRANSLATE: Distance percentage must be between 0% and 50% (positive = above average)",
"invalid_min_periods": "TRANSLATE: Minimum periods count must be between 1 and 10",
"invalid_period_length": "Periodlängden måste vara minst 15 minuter (multiplar av 15).",
"invalid_gap_count": "Gaptolerans måste vara mellan 0 och 8",
@ -916,4 +917,4 @@
}
},
"title": "Tibber Prisinformation & Betyg"
}
}

View file

@ -0,0 +1,417 @@
"""Tests for config flow validators."""
from __future__ import annotations
import pytest
from custom_components.tibber_prices.config_flow_handlers.validators import (
validate_best_price_distance_percentage,
validate_distance_percentage,
validate_flex_percentage,
validate_gap_count,
validate_min_periods,
validate_period_length,
validate_price_rating_threshold_high,
validate_price_rating_threshold_low,
validate_price_rating_thresholds,
validate_price_trend_falling,
validate_price_trend_rising,
validate_relaxation_attempts,
validate_volatility_threshold_high,
validate_volatility_threshold_moderate,
validate_volatility_threshold_very_high,
validate_volatility_thresholds,
)
from custom_components.tibber_prices.const import (
MAX_DISTANCE_PERCENTAGE,
MAX_FLEX_PERCENTAGE,
MAX_GAP_COUNT,
MAX_MIN_PERIODS,
MAX_PRICE_RATING_THRESHOLD_HIGH,
MAX_PRICE_RATING_THRESHOLD_LOW,
MAX_PRICE_TREND_FALLING,
MAX_PRICE_TREND_RISING,
MAX_RELAXATION_ATTEMPTS,
MAX_VOLATILITY_THRESHOLD_HIGH,
MAX_VOLATILITY_THRESHOLD_MODERATE,
MAX_VOLATILITY_THRESHOLD_VERY_HIGH,
MIN_GAP_COUNT,
MIN_PERIOD_LENGTH,
MIN_PRICE_RATING_THRESHOLD_HIGH,
MIN_PRICE_RATING_THRESHOLD_LOW,
MIN_PRICE_TREND_FALLING,
MIN_PRICE_TREND_RISING,
MIN_RELAXATION_ATTEMPTS,
MIN_VOLATILITY_THRESHOLD_HIGH,
MIN_VOLATILITY_THRESHOLD_MODERATE,
MIN_VOLATILITY_THRESHOLD_VERY_HIGH,
)
class TestPeriodLengthValidation:
"""Test period length validation."""
def test_valid_period_lengths(self) -> None:
"""Test valid period lengths (multiples of 15)."""
assert validate_period_length(15) is True # Minimum
assert validate_period_length(30) is True
assert validate_period_length(45) is True
assert validate_period_length(60) is True
assert validate_period_length(120) is True
assert validate_period_length(MIN_PERIOD_LENGTH) is True
def test_invalid_period_lengths(self) -> None:
"""Test invalid period lengths (not multiples of 15)."""
assert validate_period_length(0) is False
assert validate_period_length(10) is False
assert validate_period_length(20) is False
assert validate_period_length(25) is False
assert validate_period_length(40) is False
assert validate_period_length(-15) is False # Negative
class TestFlexPercentageValidation:
"""Test flex percentage validation."""
def test_valid_positive_flex(self) -> None:
"""Test valid positive flex values (Best Price)."""
assert validate_flex_percentage(0) is True
assert validate_flex_percentage(10) is True
assert validate_flex_percentage(15) is True
assert validate_flex_percentage(25) is True
assert validate_flex_percentage(MAX_FLEX_PERCENTAGE) is True
def test_valid_negative_flex(self) -> None:
"""Test valid negative flex values (Peak Price)."""
assert validate_flex_percentage(-10) is True
assert validate_flex_percentage(-20) is True
assert validate_flex_percentage(-30) is True
assert validate_flex_percentage(-MAX_FLEX_PERCENTAGE) is True
def test_invalid_flex(self) -> None:
"""Test invalid flex values (out of bounds)."""
assert validate_flex_percentage(MAX_FLEX_PERCENTAGE + 1) is False
assert validate_flex_percentage(-MAX_FLEX_PERCENTAGE - 1) is False
assert validate_flex_percentage(100) is False
assert validate_flex_percentage(-100) is False
class TestMinPeriodsValidation:
"""Test minimum periods count validation."""
def test_valid_min_periods(self) -> None:
"""Test valid min periods values."""
assert validate_min_periods(1) is True
assert validate_min_periods(2) is True
assert validate_min_periods(3) is True
assert validate_min_periods(MAX_MIN_PERIODS) is True
def test_invalid_min_periods(self) -> None:
"""Test invalid min periods values."""
assert validate_min_periods(0) is False
assert validate_min_periods(-1) is False
assert validate_min_periods(MAX_MIN_PERIODS + 1) is False
class TestDistancePercentageValidation:
"""Test distance from average percentage validation."""
def test_valid_best_price_distance(self) -> None:
"""Test valid Best Price distance (negative values)."""
assert validate_best_price_distance_percentage(0) is True
assert validate_best_price_distance_percentage(-5) is True
assert validate_best_price_distance_percentage(-10) is True
assert validate_best_price_distance_percentage(-25) is True
assert validate_best_price_distance_percentage(-MAX_DISTANCE_PERCENTAGE) is True
def test_invalid_best_price_distance(self) -> None:
"""Test invalid Best Price distance (positive or out of bounds)."""
assert validate_best_price_distance_percentage(5) is False # Positive not allowed
assert validate_best_price_distance_percentage(10) is False
assert validate_best_price_distance_percentage(-MAX_DISTANCE_PERCENTAGE - 1) is False
def test_valid_peak_price_distance(self) -> None:
"""Test valid Peak Price distance (positive values)."""
assert validate_distance_percentage(0) is True
assert validate_distance_percentage(5) is True
assert validate_distance_percentage(10) is True
assert validate_distance_percentage(25) is True
assert validate_distance_percentage(MAX_DISTANCE_PERCENTAGE) is True
def test_invalid_peak_price_distance(self) -> None:
"""Test invalid Peak Price distance (negative or out of bounds)."""
assert validate_distance_percentage(-5) is False # Negative not allowed
assert validate_distance_percentage(-10) is False
assert validate_distance_percentage(MAX_DISTANCE_PERCENTAGE + 1) is False
class TestGapCountValidation:
"""Test gap count validation."""
def test_valid_gap_counts(self) -> None:
"""Test valid gap count values."""
assert validate_gap_count(MIN_GAP_COUNT) is True
assert validate_gap_count(0) is True
assert validate_gap_count(1) is True
assert validate_gap_count(4) is True
assert validate_gap_count(MAX_GAP_COUNT) is True
def test_invalid_gap_counts(self) -> None:
"""Test invalid gap count values."""
assert validate_gap_count(MIN_GAP_COUNT - 1) is False
assert validate_gap_count(MAX_GAP_COUNT + 1) is False
assert validate_gap_count(-5) is False
class TestRelaxationAttemptsValidation:
"""Test relaxation attempts validation."""
def test_valid_relaxation_attempts(self) -> None:
"""Test valid relaxation attempts values."""
assert validate_relaxation_attempts(MIN_RELAXATION_ATTEMPTS) is True
assert validate_relaxation_attempts(5) is True
assert validate_relaxation_attempts(11) is True
assert validate_relaxation_attempts(MAX_RELAXATION_ATTEMPTS) is True
def test_invalid_relaxation_attempts(self) -> None:
"""Test invalid relaxation attempts values."""
assert validate_relaxation_attempts(MIN_RELAXATION_ATTEMPTS - 1) is False
assert validate_relaxation_attempts(0) is False
assert validate_relaxation_attempts(MAX_RELAXATION_ATTEMPTS + 1) is False
class TestPriceRatingThresholdValidation:
"""Test price rating threshold validation."""
def test_valid_threshold_low(self) -> None:
"""Test valid low threshold values (negative)."""
assert validate_price_rating_threshold_low(MIN_PRICE_RATING_THRESHOLD_LOW) is True
assert validate_price_rating_threshold_low(-20) is True
assert validate_price_rating_threshold_low(-10) is True
assert validate_price_rating_threshold_low(MAX_PRICE_RATING_THRESHOLD_LOW) is True
def test_invalid_threshold_low(self) -> None:
"""Test invalid low threshold values."""
assert validate_price_rating_threshold_low(MIN_PRICE_RATING_THRESHOLD_LOW - 1) is False
assert validate_price_rating_threshold_low(MAX_PRICE_RATING_THRESHOLD_LOW + 1) is False
assert validate_price_rating_threshold_low(0) is False # Must be negative
assert validate_price_rating_threshold_low(10) is False # Must be negative
def test_valid_threshold_high(self) -> None:
"""Test valid high threshold values (positive)."""
assert validate_price_rating_threshold_high(MIN_PRICE_RATING_THRESHOLD_HIGH) is True
assert validate_price_rating_threshold_high(10) is True
assert validate_price_rating_threshold_high(20) is True
assert validate_price_rating_threshold_high(MAX_PRICE_RATING_THRESHOLD_HIGH) is True
def test_invalid_threshold_high(self) -> None:
"""Test invalid high threshold values."""
assert validate_price_rating_threshold_high(MIN_PRICE_RATING_THRESHOLD_HIGH - 1) is False
assert validate_price_rating_threshold_high(MAX_PRICE_RATING_THRESHOLD_HIGH + 1) is False
assert validate_price_rating_threshold_high(0) is False # Must be positive
assert validate_price_rating_threshold_high(-10) is False # Must be positive
def test_valid_threshold_combinations(self) -> None:
"""Test valid combinations of low and high thresholds."""
# Standard defaults
assert validate_price_rating_thresholds(-20, 10) is True
# Wide range
assert validate_price_rating_thresholds(-50, 50) is True
# Narrow range
assert validate_price_rating_thresholds(-5, 5) is True
def test_invalid_threshold_combinations(self) -> None:
"""Test invalid combinations of low and high thresholds."""
# Low threshold out of range
assert validate_price_rating_thresholds(-60, 10) is False
# High threshold out of range
assert validate_price_rating_thresholds(-20, 60) is False
# Both out of range
assert validate_price_rating_thresholds(-100, 100) is False
# Low must be negative, high must be positive (crossing is OK)
assert validate_price_rating_thresholds(-20, 10) is True
class TestVolatilityThresholdValidation:
"""Test volatility threshold validation."""
def test_valid_threshold_moderate(self) -> None:
"""Test valid moderate threshold values."""
assert validate_volatility_threshold_moderate(MIN_VOLATILITY_THRESHOLD_MODERATE) is True
assert validate_volatility_threshold_moderate(10.0) is True
assert validate_volatility_threshold_moderate(15.0) is True
assert validate_volatility_threshold_moderate(MAX_VOLATILITY_THRESHOLD_MODERATE) is True
def test_invalid_threshold_moderate(self) -> None:
"""Test invalid moderate threshold values."""
assert validate_volatility_threshold_moderate(MIN_VOLATILITY_THRESHOLD_MODERATE - 1) is False
assert validate_volatility_threshold_moderate(MAX_VOLATILITY_THRESHOLD_MODERATE + 1) is False
assert validate_volatility_threshold_moderate(0.0) is False
def test_valid_threshold_high(self) -> None:
"""Test valid high threshold values."""
assert validate_volatility_threshold_high(MIN_VOLATILITY_THRESHOLD_HIGH) is True
assert validate_volatility_threshold_high(25.0) is True
assert validate_volatility_threshold_high(30.0) is True
assert validate_volatility_threshold_high(MAX_VOLATILITY_THRESHOLD_HIGH) is True
def test_invalid_threshold_high(self) -> None:
"""Test invalid high threshold values."""
assert validate_volatility_threshold_high(MIN_VOLATILITY_THRESHOLD_HIGH - 1) is False
assert validate_volatility_threshold_high(MAX_VOLATILITY_THRESHOLD_HIGH + 1) is False
def test_valid_threshold_very_high(self) -> None:
"""Test valid very high threshold values."""
assert validate_volatility_threshold_very_high(MIN_VOLATILITY_THRESHOLD_VERY_HIGH) is True
assert validate_volatility_threshold_very_high(50.0) is True
assert validate_volatility_threshold_very_high(60.0) is True
assert validate_volatility_threshold_very_high(MAX_VOLATILITY_THRESHOLD_VERY_HIGH) is True
def test_invalid_threshold_very_high(self) -> None:
"""Test invalid very high threshold values."""
assert validate_volatility_threshold_very_high(MIN_VOLATILITY_THRESHOLD_VERY_HIGH - 1) is False
assert validate_volatility_threshold_very_high(MAX_VOLATILITY_THRESHOLD_VERY_HIGH + 1) is False
def test_valid_threshold_combinations(self) -> None:
"""Test valid combinations of all three volatility thresholds."""
# Standard defaults
assert validate_volatility_thresholds(10.0, 25.0, 50.0) is True
# Tight clustering
assert validate_volatility_thresholds(20.0, 21.0, 36.0) is True
# Wide spacing
assert validate_volatility_thresholds(5.0, 30.0, 80.0) is True
def test_invalid_threshold_combinations(self) -> None:
"""Test invalid combinations of volatility thresholds."""
# Moderate out of range
assert validate_volatility_thresholds(0.0, 25.0, 50.0) is False
# High out of range
assert validate_volatility_thresholds(10.0, 50.0, 50.0) is False
# Very high out of range
assert validate_volatility_thresholds(10.0, 25.0, 100.0) is False
# Wrong order: moderate >= high
assert validate_volatility_thresholds(25.0, 25.0, 50.0) is False
assert validate_volatility_thresholds(30.0, 25.0, 50.0) is False
# Wrong order: high >= very_high
assert validate_volatility_thresholds(10.0, 50.0, 50.0) is False
assert validate_volatility_thresholds(10.0, 60.0, 50.0) is False
class TestPriceTrendThresholdValidation:
"""Test price trend threshold validation."""
def test_valid_threshold_rising(self) -> None:
"""Test valid rising trend threshold values (positive)."""
assert validate_price_trend_rising(MIN_PRICE_TREND_RISING) is True
assert validate_price_trend_rising(10) is True
assert validate_price_trend_rising(25) is True
assert validate_price_trend_rising(MAX_PRICE_TREND_RISING) is True
def test_invalid_threshold_rising(self) -> None:
"""Test invalid rising trend threshold values."""
assert validate_price_trend_rising(MIN_PRICE_TREND_RISING - 1) is False
assert validate_price_trend_rising(0) is False
assert validate_price_trend_rising(MAX_PRICE_TREND_RISING + 1) is False
assert validate_price_trend_rising(-10) is False # Must be positive
def test_valid_threshold_falling(self) -> None:
"""Test valid falling trend threshold values (negative)."""
assert validate_price_trend_falling(MIN_PRICE_TREND_FALLING) is True
assert validate_price_trend_falling(-25) is True
assert validate_price_trend_falling(-10) is True
assert validate_price_trend_falling(MAX_PRICE_TREND_FALLING) is True
def test_invalid_threshold_falling(self) -> None:
"""Test invalid falling trend threshold values."""
assert validate_price_trend_falling(MIN_PRICE_TREND_FALLING - 1) is False
assert validate_price_trend_falling(0) is False
assert validate_price_trend_falling(MAX_PRICE_TREND_FALLING + 1) is False
assert validate_price_trend_falling(10) is False # Must be negative
class TestBoundaryConditions:
"""Test boundary conditions for all validators."""
def test_exact_boundaries(self) -> None:
"""Test that validators accept exact boundary values."""
# Period length
assert validate_period_length(MIN_PERIOD_LENGTH) is True
# Flex
assert validate_flex_percentage(MAX_FLEX_PERCENTAGE) is True
assert validate_flex_percentage(-MAX_FLEX_PERCENTAGE) is True
# Min periods
assert validate_min_periods(1) is True
assert validate_min_periods(MAX_MIN_PERIODS) is True
# Distance (Best Price)
assert validate_best_price_distance_percentage(0) is True
assert validate_best_price_distance_percentage(-MAX_DISTANCE_PERCENTAGE) is True
# Distance (Peak Price)
assert validate_distance_percentage(0) is True
assert validate_distance_percentage(MAX_DISTANCE_PERCENTAGE) is True
# Gap count
assert validate_gap_count(MIN_GAP_COUNT) is True
assert validate_gap_count(MAX_GAP_COUNT) is True
# Relaxation attempts
assert validate_relaxation_attempts(MIN_RELAXATION_ATTEMPTS) is True
assert validate_relaxation_attempts(MAX_RELAXATION_ATTEMPTS) is True
def test_just_outside_boundaries(self) -> None:
"""Test that validators reject values just outside boundaries."""
# Flex
assert validate_flex_percentage(MAX_FLEX_PERCENTAGE + 1) is False
assert validate_flex_percentage(-MAX_FLEX_PERCENTAGE - 1) is False
# Min periods
assert validate_min_periods(0) is False
assert validate_min_periods(MAX_MIN_PERIODS + 1) is False
# Distance (Best Price)
assert validate_best_price_distance_percentage(1) is False # Must be ≤0
assert validate_best_price_distance_percentage(-MAX_DISTANCE_PERCENTAGE - 1) is False
# Distance (Peak Price)
assert validate_distance_percentage(-1) is False # Must be ≥0
assert validate_distance_percentage(MAX_DISTANCE_PERCENTAGE + 1) is False
class TestFloatPrecision:
"""Test handling of float precision in validators."""
def test_float_precision_distance(self) -> None:
"""Test float precision for distance validators."""
# Best Price
assert validate_best_price_distance_percentage(-5.5) is True
assert validate_best_price_distance_percentage(-0.1) is True
assert validate_best_price_distance_percentage(-49.9) is True
# Peak Price
assert validate_distance_percentage(5.5) is True
assert validate_distance_percentage(0.1) is True
assert validate_distance_percentage(49.9) is True
def test_float_precision_volatility(self) -> None:
"""Test float precision for volatility validators."""
assert validate_volatility_threshold_moderate(10.5) is True
assert validate_volatility_threshold_high(25.3) is True
assert validate_volatility_threshold_very_high(50.7) is True
if __name__ == "__main__":
pytest.main([__file__, "-v"])

View file

@ -64,7 +64,7 @@ def period_config() -> TibberPricesPeriodConfig:
return TibberPricesPeriodConfig(
reverse_sort=False, # Best price (cheap periods)
flex=0.50, # 50% flexibility
min_distance_from_avg=5.0,
min_distance_from_avg=-5.0, # -5% below average
min_period_length=60, # 60 minutes minimum
threshold_low=20.0,
threshold_high=30.0,