mirror of
https://github.com/jpawlowski/hass.tibber_prices.git
synced 2026-03-29 21:03:40 +00:00
fix(period_handlers): normalize flex and min_distance to absolute values
Fixed critical sign convention bug where negative user-facing values (e.g., peak_price_flex=-20%) weren't normalized for internal calculations, causing incorrect period filtering. Changes: - periods.py: Added abs() normalization for flex and min_distance_from_avg - core.py: Added comment documenting flex normalization by get_period_config() - level_filtering.py: Simplified check_interval_criteria() to work with normalized positive values only, removed complex negative price handling - relaxation.py: Removed sign handling since values are pre-normalized Internal convention: - User-facing: Best price uses positive (+15%), Peak price uses negative (-20%) - Internal: Always positive (0.15 or 0.20) with reverse_sort flag for direction Added comprehensive regression tests: - test_best_price_e2e.py: Validates Best price periods generate correctly - test_peak_price_e2e.py: Validates Peak price periods generate correctly - test_level_filtering.py: Unit tests for flex/distance filter logic Impact: Peak price periods now generate correctly. Bug caused 100% FLEX filtering (192/192 intervals blocked) → 0 periods found. Fix ensures reasonable filtering (~40-50%) with periods successfully generated.
This commit is contained in:
parent
476b0f6ef8
commit
f2627a5292
7 changed files with 1513 additions and 48 deletions
|
|
@ -73,7 +73,7 @@ def calculate_periods(
|
||||||
|
|
||||||
# Extract config values
|
# Extract config values
|
||||||
reverse_sort = config.reverse_sort
|
reverse_sort = config.reverse_sort
|
||||||
flex_raw = config.flex
|
flex_raw = config.flex # Already normalized to positive by get_period_config()
|
||||||
min_distance_from_avg = config.min_distance_from_avg
|
min_distance_from_avg = config.min_distance_from_avg
|
||||||
min_period_length = config.min_period_length
|
min_period_length = config.min_period_length
|
||||||
threshold_low = config.threshold_low
|
threshold_low = config.threshold_low
|
||||||
|
|
@ -81,13 +81,14 @@ def calculate_periods(
|
||||||
|
|
||||||
# CRITICAL: Hard cap flex at 50% to prevent degenerate behavior
|
# CRITICAL: Hard cap flex at 50% to prevent degenerate behavior
|
||||||
# Above 50%, period detection becomes unreliable (too many intervals qualify)
|
# Above 50%, period detection becomes unreliable (too many intervals qualify)
|
||||||
|
# NOTE: flex_raw is already positive from normalization in get_period_config()
|
||||||
flex = flex_raw
|
flex = flex_raw
|
||||||
if abs(flex_raw) > MAX_SAFE_FLEX:
|
if flex_raw > MAX_SAFE_FLEX:
|
||||||
flex = MAX_SAFE_FLEX if flex_raw > 0 else -MAX_SAFE_FLEX
|
flex = MAX_SAFE_FLEX
|
||||||
_LOGGER.warning(
|
_LOGGER.warning(
|
||||||
"Flex %.1f%% exceeds maximum safe value! Capping at %.0f%%. "
|
"Flex %.1f%% exceeds maximum safe value! Capping at %.0f%%. "
|
||||||
"Recommendation: Use 15-20%% with relaxation enabled, or 25-35%% without relaxation.",
|
"Recommendation: Use 15-20%% with relaxation enabled, or 25-35%% without relaxation.",
|
||||||
abs(flex_raw) * 100,
|
flex_raw * 100,
|
||||||
MAX_SAFE_FLEX * 100,
|
MAX_SAFE_FLEX * 100,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -109,6 +109,11 @@ def check_interval_criteria(
|
||||||
"""
|
"""
|
||||||
Check if interval meets flex and minimum distance criteria.
|
Check if interval meets flex and minimum distance criteria.
|
||||||
|
|
||||||
|
CRITICAL: This function works with NORMALIZED values (always positive):
|
||||||
|
- criteria.flex: Always positive (e.g., 0.20 for 20%)
|
||||||
|
- criteria.min_distance_from_avg: Always positive (e.g., 5.0 for 5%)
|
||||||
|
- criteria.reverse_sort: Determines direction (True=Peak, False=Best)
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
price: Interval price
|
price: Interval price
|
||||||
criteria: Interval criteria (ref_price, avg_price, flex, etc.)
|
criteria: Interval criteria (ref_price, avg_price, flex, etc.)
|
||||||
|
|
@ -117,54 +122,51 @@ def check_interval_criteria(
|
||||||
Tuple of (in_flex, meets_min_distance)
|
Tuple of (in_flex, meets_min_distance)
|
||||||
|
|
||||||
"""
|
"""
|
||||||
# CRITICAL: Handle negative reference prices correctly
|
# Normalize inputs to absolute values for consistent calculation
|
||||||
# For best price (reverse_sort=False): ref_price is daily minimum
|
flex_abs = abs(criteria.flex)
|
||||||
# For peak price (reverse_sort=True): ref_price is daily maximum
|
min_distance_abs = abs(criteria.min_distance_from_avg)
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# FLEX FILTER: Check if price is within flex threshold of reference
|
||||||
|
# ============================================================
|
||||||
|
# Reference price is:
|
||||||
|
# - Peak price (reverse_sort=True): daily MAXIMUM
|
||||||
|
# - Best price (reverse_sort=False): daily MINIMUM
|
||||||
#
|
#
|
||||||
# Flex determines price band:
|
# Flex band calculation (using absolute values):
|
||||||
# - Best price: [ref_price, ref_price + abs(ref_price) * flex]
|
# - Peak price: [max - max*flex, max] → accept prices near the maximum
|
||||||
# - Peak price: [ref_price - abs(ref_price) * flex, ref_price]
|
# - Best price: [min, min + min*flex] → accept prices near the minimum
|
||||||
#
|
#
|
||||||
# Examples (flex=15%):
|
# Examples with flex=20%:
|
||||||
# Positive ref (10 ct, best): [10, 11.5] → max = 10 + 10*0.15 = 11.5
|
# - Peak: max=30 ct → accept [24, 30] ct (prices ≥ 24 ct)
|
||||||
# Negative ref (-10 ct, best): [-10, -8.5] → max = -10 + 10*0.15 = -8.5 (less negative = more expensive)
|
# - Best: min=10 ct → accept [10, 12] ct (prices ≤ 12 ct)
|
||||||
# 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)
|
|
||||||
|
|
||||||
if criteria.ref_price == 0:
|
if criteria.ref_price == 0:
|
||||||
# Zero reference: flex has no effect, use strict equality
|
# Zero reference: flex has no effect, use strict equality
|
||||||
in_flex = price == 0
|
in_flex = price == 0
|
||||||
else:
|
else:
|
||||||
# Calculate flex threshold using absolute value of reference
|
# Calculate flex amount using absolute value
|
||||||
flex_amount = abs(criteria.ref_price) * criteria.flex
|
flex_amount = abs(criteria.ref_price) * flex_abs
|
||||||
|
|
||||||
if criteria.reverse_sort:
|
if criteria.reverse_sort:
|
||||||
# Peak price: price must be >= (ref_price - flex_amount)
|
# Peak price: accept prices >= (ref_price - flex_amount)
|
||||||
# For negative ref: more negative is cheaper, so subtract
|
# Prices must be CLOSE TO or AT the maximum
|
||||||
# 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
|
flex_threshold = criteria.ref_price - flex_amount
|
||||||
in_flex = price >= flex_threshold and price <= criteria.ref_price
|
in_flex = price >= flex_threshold
|
||||||
else:
|
else:
|
||||||
# Best price: price must be in range [ref_price, ref_price + flex_amount]
|
# Best price: accept prices <= (ref_price + flex_amount)
|
||||||
# For negative ref: less negative is more expensive, so add
|
# Prices must be CLOSE TO or AT the minimum
|
||||||
# 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
|
flex_threshold = criteria.ref_price + flex_amount
|
||||||
in_flex = price >= criteria.ref_price and price <= flex_threshold
|
in_flex = price >= criteria.ref_price and price <= flex_threshold
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# MIN_DISTANCE FILTER: Check if price is far enough from average
|
||||||
|
# ============================================================
|
||||||
# CRITICAL: Adjust min_distance dynamically based on flex to prevent conflicts
|
# 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%)
|
# Problem: High flex (e.g., 50%) can conflict with fixed min_distance (e.g., 5%)
|
||||||
# Solution: When flex is high, reduce min_distance requirement proportionally
|
# Solution: When flex is high, reduce min_distance requirement proportionally
|
||||||
#
|
|
||||||
# At low flex (≤20%), use full min_distance (e.g., 5%)
|
|
||||||
# At high flex (≥40%), reduce min_distance to avoid over-filtering
|
|
||||||
# Linear interpolation between 20-40% flex range
|
|
||||||
|
|
||||||
adjusted_min_distance = criteria.min_distance_from_avg
|
adjusted_min_distance = min_distance_abs
|
||||||
flex_abs = abs(criteria.flex)
|
|
||||||
|
|
||||||
if flex_abs > FLEX_SCALING_THRESHOLD:
|
if flex_abs > FLEX_SCALING_THRESHOLD:
|
||||||
# Scale down min_distance as flex increases
|
# Scale down min_distance as flex increases
|
||||||
|
|
@ -173,7 +175,7 @@ def check_interval_criteria(
|
||||||
# At 50% flex: multiplier = 0.25 (quarter min_distance)
|
# At 50% flex: multiplier = 0.25 (quarter min_distance)
|
||||||
flex_excess = flex_abs - 0.20 # How much above 20%
|
flex_excess = flex_abs - 0.20 # How much above 20%
|
||||||
scale_factor = max(0.25, 1.0 - (flex_excess * 2.5)) # Linear reduction, min 25%
|
scale_factor = max(0.25, 1.0 - (flex_excess * 2.5)) # Linear reduction, min 25%
|
||||||
adjusted_min_distance = criteria.min_distance_from_avg * scale_factor
|
adjusted_min_distance = min_distance_abs * scale_factor
|
||||||
|
|
||||||
# Log adjustment at DEBUG level (only when significant reduction)
|
# Log adjustment at DEBUG level (only when significant reduction)
|
||||||
if scale_factor < SCALE_FACTOR_WARNING_THRESHOLD:
|
if scale_factor < SCALE_FACTOR_WARNING_THRESHOLD:
|
||||||
|
|
@ -183,18 +185,21 @@ def check_interval_criteria(
|
||||||
_LOGGER.debug(
|
_LOGGER.debug(
|
||||||
"High flex %.1f%% detected: Reducing min_distance %.1f%% → %.1f%% (scale %.2f)",
|
"High flex %.1f%% detected: Reducing min_distance %.1f%% → %.1f%% (scale %.2f)",
|
||||||
flex_abs * 100,
|
flex_abs * 100,
|
||||||
criteria.min_distance_from_avg,
|
min_distance_abs,
|
||||||
adjusted_min_distance,
|
adjusted_min_distance,
|
||||||
scale_factor,
|
scale_factor,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Minimum distance from average (using adjusted value)
|
# Calculate threshold from average (using normalized positive distance)
|
||||||
# Uniform formula: avg * (1 + distance/100) works for both Best (negative) and Peak (positive)
|
# - Peak price: threshold = avg * (1 + distance/100) → prices must be ABOVE avg+distance
|
||||||
# - Best: distance=-5% → avg * 0.95 (5% below average)
|
# - Best price: threshold = avg * (1 - distance/100) → prices must be BELOW avg-distance
|
||||||
# - Peak: distance=+5% → avg * 1.05 (5% above average)
|
if criteria.reverse_sort:
|
||||||
|
# Peak: price must be >= avg * (1 + distance%)
|
||||||
min_distance_threshold = criteria.avg_price * (1 + adjusted_min_distance / 100)
|
min_distance_threshold = criteria.avg_price * (1 + adjusted_min_distance / 100)
|
||||||
|
meets_min_distance = price >= min_distance_threshold
|
||||||
# Check: Peak (≥ threshold) or Best (≤ threshold)
|
else:
|
||||||
meets_min_distance = price >= min_distance_threshold if criteria.reverse_sort else price <= min_distance_threshold
|
# Best: price must be <= avg * (1 - distance%)
|
||||||
|
min_distance_threshold = criteria.avg_price * (1 - adjusted_min_distance / 100)
|
||||||
|
meets_min_distance = price <= min_distance_threshold
|
||||||
|
|
||||||
return in_flex, meets_min_distance
|
return in_flex, meets_min_distance
|
||||||
|
|
|
||||||
|
|
@ -431,8 +431,9 @@ def relax_all_prices( # noqa: PLR0913 - Comprehensive filter relaxation require
|
||||||
original_level_filter,
|
original_level_filter,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# NOTE: config.flex is already normalized to positive by get_period_config()
|
||||||
relaxed_config = config._replace(
|
relaxed_config = config._replace(
|
||||||
flex=current_flex if config.flex >= 0 else -current_flex,
|
flex=current_flex, # Already positive from normalization
|
||||||
level_filter="any",
|
level_filter="any",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -143,14 +143,30 @@ class TibberPricesPeriodCalculator:
|
||||||
)
|
)
|
||||||
|
|
||||||
# Convert flex from percentage to decimal (e.g., 5 -> 0.05)
|
# Convert flex from percentage to decimal (e.g., 5 -> 0.05)
|
||||||
|
# CRITICAL: Normalize to absolute value for internal calculations
|
||||||
|
# User-facing values use sign convention:
|
||||||
|
# - Best price: positive (e.g., +15% above minimum)
|
||||||
|
# - Peak price: negative (e.g., -20% below maximum)
|
||||||
|
# Internal calculations always use positive values with reverse_sort flag
|
||||||
try:
|
try:
|
||||||
flex = float(flex) / 100
|
flex = abs(float(flex)) / 100 # Always positive internally
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
flex = _const.DEFAULT_BEST_PRICE_FLEX / 100 if not reverse_sort else _const.DEFAULT_PEAK_PRICE_FLEX / 100
|
flex = (
|
||||||
|
abs(_const.DEFAULT_BEST_PRICE_FLEX) / 100
|
||||||
|
if not reverse_sort
|
||||||
|
else abs(_const.DEFAULT_PEAK_PRICE_FLEX) / 100
|
||||||
|
)
|
||||||
|
|
||||||
|
# CRITICAL: Normalize min_distance_from_avg to absolute value
|
||||||
|
# User-facing values use sign convention:
|
||||||
|
# - Best price: negative (e.g., -5% below average)
|
||||||
|
# - Peak price: positive (e.g., +5% above average)
|
||||||
|
# Internal calculations always use positive values with reverse_sort flag
|
||||||
|
min_distance_from_avg_normalized = abs(float(min_distance_from_avg))
|
||||||
|
|
||||||
config = {
|
config = {
|
||||||
"flex": flex,
|
"flex": flex,
|
||||||
"min_distance_from_avg": float(min_distance_from_avg),
|
"min_distance_from_avg": min_distance_from_avg_normalized,
|
||||||
"min_period_length": int(min_period_length),
|
"min_period_length": int(min_period_length),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
376
tests/test_best_price_e2e.py
Normal file
376
tests/test_best_price_e2e.py
Normal file
|
|
@ -0,0 +1,376 @@
|
||||||
|
"""
|
||||||
|
End-to-End Tests for Best Price Period Generation (Nov 2025 Bug Fix).
|
||||||
|
|
||||||
|
These tests validate that the sign convention bug fix works correctly:
|
||||||
|
- Bug: Negative flex for peak wasn't normalized → affected period calculation
|
||||||
|
- Fix: abs() normalization in periods.py ensures consistent behavior
|
||||||
|
|
||||||
|
Test coverage matches manual testing checklist:
|
||||||
|
1. ✅ Best periods generate (not 0)
|
||||||
|
2. ✅ FLEX filter stats reasonable (~20-40%, not 100%)
|
||||||
|
3. ✅ Relaxation succeeds at reasonable flex (not maxed at 50%)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from unittest.mock import Mock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from custom_components.tibber_prices.coordinator.period_handlers import (
|
||||||
|
TibberPricesPeriodConfig,
|
||||||
|
calculate_periods_with_relaxation,
|
||||||
|
)
|
||||||
|
from custom_components.tibber_prices.coordinator.time_service import (
|
||||||
|
TibberPricesTimeService,
|
||||||
|
)
|
||||||
|
from homeassistant.util import dt as dt_util
|
||||||
|
|
||||||
|
|
||||||
|
def _create_realistic_intervals() -> list[dict]:
|
||||||
|
"""
|
||||||
|
Create realistic test data matching German market Nov 22, 2025.
|
||||||
|
|
||||||
|
Pattern: Morning peak (6-9h), midday low (9-15h), evening moderate (15-24h).
|
||||||
|
Daily stats: Min=30.44ct, Avg=33.26ct, Max=36.03ct
|
||||||
|
"""
|
||||||
|
base_time = dt_util.parse_datetime("2025-11-22T00:00:00+01:00")
|
||||||
|
assert base_time is not None
|
||||||
|
|
||||||
|
daily_min, daily_avg, daily_max = 0.3044, 0.3326, 0.3603
|
||||||
|
|
||||||
|
def _create_interval(hour: int, minute: int, price: float, level: str, rating: str) -> dict:
|
||||||
|
"""Create a single interval dict."""
|
||||||
|
return {
|
||||||
|
"startsAt": base_time.replace(hour=hour, minute=minute), # datetime object
|
||||||
|
"total": price,
|
||||||
|
"level": level,
|
||||||
|
"rating_level": rating,
|
||||||
|
"_original_price": price,
|
||||||
|
"trailing_avg_24h": daily_avg,
|
||||||
|
"daily_min": daily_min,
|
||||||
|
"daily_avg": daily_avg,
|
||||||
|
"daily_max": daily_max,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Build all intervals as list comprehensions
|
||||||
|
intervals = []
|
||||||
|
|
||||||
|
# Overnight (00:00-06:00) - NORMAL
|
||||||
|
intervals.extend(
|
||||||
|
[_create_interval(hour, minute, 0.318, "NORMAL", "NORMAL") for hour in range(6) for minute in [0, 15, 30, 45]]
|
||||||
|
)
|
||||||
|
|
||||||
|
# Morning spike (06:00-09:00) - EXPENSIVE
|
||||||
|
intervals.extend(
|
||||||
|
[
|
||||||
|
_create_interval(
|
||||||
|
hour,
|
||||||
|
minute,
|
||||||
|
price := 0.33 + (hour - 6) * 0.01,
|
||||||
|
"EXPENSIVE" if price > 0.34 else "NORMAL",
|
||||||
|
"HIGH" if price > 0.35 else "NORMAL",
|
||||||
|
)
|
||||||
|
for hour in range(6, 9)
|
||||||
|
for minute in [0, 15, 30, 45]
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
# Midday low (09:00-15:00) - CHEAP
|
||||||
|
intervals.extend(
|
||||||
|
[
|
||||||
|
_create_interval(hour, minute, 0.305 + (hour - 12) * 0.002, "CHEAP", "LOW")
|
||||||
|
for hour in range(9, 15)
|
||||||
|
for minute in [0, 15, 30, 45]
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
# Evening moderate (15:00-24:00) - NORMAL to EXPENSIVE
|
||||||
|
intervals.extend(
|
||||||
|
[
|
||||||
|
_create_interval(
|
||||||
|
hour,
|
||||||
|
minute,
|
||||||
|
price := 0.32 + (hour - 15) * 0.005,
|
||||||
|
"EXPENSIVE" if price > 0.34 else "NORMAL",
|
||||||
|
"HIGH" if price > 0.35 else "NORMAL",
|
||||||
|
)
|
||||||
|
for hour in range(15, 24)
|
||||||
|
for minute in [0, 15, 30, 45]
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
return intervals
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestBestPriceGenerationWorks:
|
||||||
|
"""Validate that best price periods generate successfully after bug fix."""
|
||||||
|
|
||||||
|
def test_best_periods_generate_successfully(self) -> None:
|
||||||
|
"""
|
||||||
|
✅ PRIMARY TEST: Best periods generate (not 0).
|
||||||
|
|
||||||
|
Validates that positive flex for BEST price mode produces periods.
|
||||||
|
"""
|
||||||
|
intervals = _create_realistic_intervals()
|
||||||
|
|
||||||
|
# Mock coordinator (minimal setup)
|
||||||
|
mock_coordinator = Mock()
|
||||||
|
mock_coordinator.config_entry = Mock()
|
||||||
|
time_service = TibberPricesTimeService(mock_coordinator)
|
||||||
|
# Mock now() to return test date
|
||||||
|
test_time = dt_util.parse_datetime("2025-11-22T12:00:00+01:00")
|
||||||
|
time_service.now = Mock(return_value=test_time)
|
||||||
|
|
||||||
|
# Create config for BEST price mode (normal positive flex)
|
||||||
|
config = TibberPricesPeriodConfig(
|
||||||
|
flex=0.15, # 15% positive (BEST price mode)
|
||||||
|
min_distance_from_avg=5.0,
|
||||||
|
min_period_length=60, # Best price uses 60min default
|
||||||
|
reverse_sort=False, # Best price mode (cheapest first)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Calculate periods with relaxation
|
||||||
|
result, _ = calculate_periods_with_relaxation(
|
||||||
|
intervals,
|
||||||
|
config=config,
|
||||||
|
enable_relaxation=True,
|
||||||
|
min_periods=2,
|
||||||
|
max_relaxation_attempts=11,
|
||||||
|
should_show_callback=lambda _: True, # Allow all levels
|
||||||
|
time=time_service,
|
||||||
|
)
|
||||||
|
|
||||||
|
periods = result.get("periods", [])
|
||||||
|
|
||||||
|
# Validation: periods found
|
||||||
|
assert len(periods) > 0, "Best periods should generate"
|
||||||
|
assert 2 <= len(periods) <= 5, f"Expected 2-5 periods, got {len(periods)}"
|
||||||
|
|
||||||
|
def test_positive_flex_produces_periods(self) -> None:
|
||||||
|
"""
|
||||||
|
✅ TEST: Positive flex produces periods in BEST mode.
|
||||||
|
|
||||||
|
Validates standard positive flex behavior for cheapest periods.
|
||||||
|
"""
|
||||||
|
intervals = _create_realistic_intervals()
|
||||||
|
|
||||||
|
mock_coordinator = Mock()
|
||||||
|
mock_coordinator.config_entry = Mock()
|
||||||
|
time_service = TibberPricesTimeService(mock_coordinator)
|
||||||
|
# Mock now() to return test date
|
||||||
|
test_time = dt_util.parse_datetime("2025-11-22T12:00:00+01:00")
|
||||||
|
time_service.now = Mock(return_value=test_time)
|
||||||
|
|
||||||
|
# Test with positive flex (standard BEST mode)
|
||||||
|
config_positive = TibberPricesPeriodConfig(
|
||||||
|
flex=0.15, # Positive for BEST mode
|
||||||
|
min_distance_from_avg=5.0,
|
||||||
|
min_period_length=60,
|
||||||
|
reverse_sort=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
result_pos, _ = calculate_periods_with_relaxation(
|
||||||
|
intervals,
|
||||||
|
config=config_positive,
|
||||||
|
enable_relaxation=True,
|
||||||
|
min_periods=2,
|
||||||
|
max_relaxation_attempts=11,
|
||||||
|
should_show_callback=lambda _: True,
|
||||||
|
time=time_service,
|
||||||
|
)
|
||||||
|
|
||||||
|
periods_pos = result_pos.get("periods", [])
|
||||||
|
|
||||||
|
# With positive flex, should find periods
|
||||||
|
assert len(periods_pos) >= 2, f"Should find periods with positive flex, got {len(periods_pos)}"
|
||||||
|
|
||||||
|
def test_periods_contain_low_prices(self) -> None:
|
||||||
|
"""
|
||||||
|
✅ TEST: Best periods contain low prices (not expensive ones).
|
||||||
|
|
||||||
|
Validates periods include cheap intervals, not expensive ones.
|
||||||
|
"""
|
||||||
|
intervals = _create_realistic_intervals()
|
||||||
|
|
||||||
|
mock_coordinator = Mock()
|
||||||
|
mock_coordinator.config_entry = Mock()
|
||||||
|
time_service = TibberPricesTimeService(mock_coordinator)
|
||||||
|
# Mock now() to return test date
|
||||||
|
test_time = dt_util.parse_datetime("2025-11-22T12:00:00+01:00")
|
||||||
|
time_service.now = Mock(return_value=test_time)
|
||||||
|
|
||||||
|
config = TibberPricesPeriodConfig(
|
||||||
|
flex=0.15,
|
||||||
|
min_distance_from_avg=5.0,
|
||||||
|
min_period_length=60,
|
||||||
|
reverse_sort=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
result, _ = calculate_periods_with_relaxation(
|
||||||
|
intervals,
|
||||||
|
config=config,
|
||||||
|
enable_relaxation=True,
|
||||||
|
min_periods=2,
|
||||||
|
max_relaxation_attempts=11,
|
||||||
|
should_show_callback=lambda _: True,
|
||||||
|
time=time_service,
|
||||||
|
)
|
||||||
|
|
||||||
|
periods = result.get("periods", [])
|
||||||
|
|
||||||
|
daily_max = intervals[0]["daily_max"]
|
||||||
|
|
||||||
|
# Check period averages are NOT near daily maximum
|
||||||
|
# Note: period prices are in cents, daily stats are in euros
|
||||||
|
for period in periods:
|
||||||
|
period_avg = period.get("price_avg", 0)
|
||||||
|
assert period_avg < daily_max * 100 * 0.95, (
|
||||||
|
f"Best period has too high avg: {period_avg:.4f} ct vs daily_max={daily_max * 100:.4f} ct"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_relaxation_works_at_reasonable_flex(self) -> None:
|
||||||
|
"""
|
||||||
|
✅ TEST: Relaxation succeeds without maxing flex at 50%.
|
||||||
|
|
||||||
|
Validates relaxation finds periods at reasonable flex levels.
|
||||||
|
"""
|
||||||
|
intervals = _create_realistic_intervals()
|
||||||
|
|
||||||
|
mock_coordinator = Mock()
|
||||||
|
mock_coordinator.config_entry = Mock()
|
||||||
|
time_service = TibberPricesTimeService(mock_coordinator)
|
||||||
|
# Mock now() to return test date
|
||||||
|
test_time = dt_util.parse_datetime("2025-11-22T12:00:00+01:00")
|
||||||
|
time_service.now = Mock(return_value=test_time)
|
||||||
|
|
||||||
|
# Lower flex to trigger relaxation
|
||||||
|
config = TibberPricesPeriodConfig(
|
||||||
|
flex=0.10, # 10% - likely needs relaxation
|
||||||
|
min_distance_from_avg=5.0,
|
||||||
|
min_period_length=60,
|
||||||
|
reverse_sort=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
result, relaxation_meta = calculate_periods_with_relaxation(
|
||||||
|
intervals,
|
||||||
|
config=config,
|
||||||
|
enable_relaxation=True,
|
||||||
|
min_periods=2,
|
||||||
|
max_relaxation_attempts=11,
|
||||||
|
should_show_callback=lambda _: True,
|
||||||
|
time=time_service,
|
||||||
|
)
|
||||||
|
|
||||||
|
periods = result.get("periods", [])
|
||||||
|
|
||||||
|
# Should find periods via relaxation
|
||||||
|
assert len(periods) >= 2, "Relaxation should find periods"
|
||||||
|
|
||||||
|
# Check if relaxation was used
|
||||||
|
if "max_flex_used" in relaxation_meta:
|
||||||
|
max_flex_used = relaxation_meta["max_flex_used"]
|
||||||
|
# Fix ensures reasonable flex is sufficient
|
||||||
|
assert max_flex_used <= 0.35, f"Flex should stay reasonable, got {max_flex_used * 100:.1f}%"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestBestPriceBugRegressionValidation:
|
||||||
|
"""Regression tests ensuring consistent behavior with peak price fix."""
|
||||||
|
|
||||||
|
def test_metadata_shows_reasonable_flex_used(self) -> None:
|
||||||
|
"""
|
||||||
|
✅ REGRESSION: Metadata shows flex used was reasonable (not 50%).
|
||||||
|
|
||||||
|
This validates FLEX filter works correctly in BEST mode too.
|
||||||
|
"""
|
||||||
|
intervals = _create_realistic_intervals()
|
||||||
|
|
||||||
|
mock_coordinator = Mock()
|
||||||
|
mock_coordinator.config_entry = Mock()
|
||||||
|
time_service = TibberPricesTimeService(mock_coordinator)
|
||||||
|
# Mock now() to return test date
|
||||||
|
test_time = dt_util.parse_datetime("2025-11-22T12:00:00+01:00")
|
||||||
|
time_service.now = Mock(return_value=test_time)
|
||||||
|
|
||||||
|
config = TibberPricesPeriodConfig(
|
||||||
|
flex=0.15,
|
||||||
|
min_distance_from_avg=5.0,
|
||||||
|
min_period_length=60,
|
||||||
|
reverse_sort=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
result, relaxation_meta = calculate_periods_with_relaxation(
|
||||||
|
intervals,
|
||||||
|
config=config,
|
||||||
|
enable_relaxation=True,
|
||||||
|
min_periods=2,
|
||||||
|
max_relaxation_attempts=11,
|
||||||
|
should_show_callback=lambda _: True,
|
||||||
|
time=time_service,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check metadata from result
|
||||||
|
metadata = result.get("metadata", {})
|
||||||
|
config_used = metadata.get("config", {})
|
||||||
|
|
||||||
|
if "flex" in config_used:
|
||||||
|
flex_used = config_used["flex"]
|
||||||
|
# Reasonable flex should be sufficient
|
||||||
|
assert 0.10 <= flex_used <= 0.35, f"Expected flex 10-35%, got {flex_used * 100:.1f}%"
|
||||||
|
|
||||||
|
# Also check relaxation metadata
|
||||||
|
if "max_flex_used" in relaxation_meta:
|
||||||
|
max_flex = relaxation_meta["max_flex_used"]
|
||||||
|
assert max_flex <= 0.35, f"Max flex should be reasonable, got {max_flex * 100:.1f}%"
|
||||||
|
|
||||||
|
def test_periods_include_cheap_intervals(self) -> None:
|
||||||
|
"""
|
||||||
|
✅ REGRESSION: Best periods include intervals near daily min.
|
||||||
|
|
||||||
|
Validates that cheap intervals are properly included in periods.
|
||||||
|
"""
|
||||||
|
intervals = _create_realistic_intervals()
|
||||||
|
|
||||||
|
mock_coordinator = Mock()
|
||||||
|
mock_coordinator.config_entry = Mock()
|
||||||
|
time_service = TibberPricesTimeService(mock_coordinator)
|
||||||
|
# Mock now() to return test date
|
||||||
|
test_time = dt_util.parse_datetime("2025-11-22T12:00:00+01:00")
|
||||||
|
time_service.now = Mock(return_value=test_time)
|
||||||
|
|
||||||
|
config = TibberPricesPeriodConfig(
|
||||||
|
flex=0.15,
|
||||||
|
min_distance_from_avg=5.0,
|
||||||
|
min_period_length=60,
|
||||||
|
reverse_sort=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
result, _ = calculate_periods_with_relaxation(
|
||||||
|
intervals,
|
||||||
|
config=config,
|
||||||
|
enable_relaxation=True,
|
||||||
|
min_periods=2,
|
||||||
|
max_relaxation_attempts=11,
|
||||||
|
should_show_callback=lambda _: True,
|
||||||
|
time=time_service,
|
||||||
|
)
|
||||||
|
|
||||||
|
periods = result.get("periods", [])
|
||||||
|
|
||||||
|
daily_avg = intervals[0]["daily_avg"]
|
||||||
|
daily_min = intervals[0]["daily_min"]
|
||||||
|
|
||||||
|
# At least one period should have low average
|
||||||
|
# Note: period prices are in cents, daily stats are in euros
|
||||||
|
min_period_avg = min(p.get("price_avg", 1.0) for p in periods)
|
||||||
|
|
||||||
|
assert min_period_avg <= daily_avg * 100 * 0.95, (
|
||||||
|
f"Best periods should have low avg: {min_period_avg:.4f} ct vs daily_avg={daily_avg * 100:.4f} ct"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check proximity to daily min
|
||||||
|
assert min_period_avg <= daily_min * 100 * 1.15, (
|
||||||
|
f"At least one period near daily_min: {min_period_avg:.4f} ct vs daily_min={daily_min * 100:.4f} ct"
|
||||||
|
)
|
||||||
685
tests/test_level_filtering.py
Normal file
685
tests/test_level_filtering.py
Normal file
|
|
@ -0,0 +1,685 @@
|
||||||
|
"""
|
||||||
|
Unit tests for level_filtering.py - Filter logic for period calculation.
|
||||||
|
|
||||||
|
This test suite validates the core filtering logic used in period calculation:
|
||||||
|
- Flex filter (price distance from daily min/max)
|
||||||
|
- Min distance filter (price distance from daily average)
|
||||||
|
- Dynamic scaling of min_distance when flex is high (>20%)
|
||||||
|
- Sign convention normalization (negative user values → positive internal values)
|
||||||
|
|
||||||
|
Regression Tests:
|
||||||
|
- Peak Price Sign Convention Bug (Nov 2025): Negative flex values blocked all peak prices
|
||||||
|
- Redundant Condition Bug (Nov 2025): "price <= ref_price" blocked all peak prices
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from custom_components.tibber_prices.coordinator.period_handlers.level_filtering import (
|
||||||
|
check_interval_criteria,
|
||||||
|
)
|
||||||
|
from custom_components.tibber_prices.coordinator.period_handlers.types import (
|
||||||
|
TibberPricesIntervalCriteria,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestFlexFilterBestPrice:
|
||||||
|
"""Test flex filter logic for Best Price (reverse_sort=False)."""
|
||||||
|
|
||||||
|
def test_interval_within_flex_threshold(self) -> None:
|
||||||
|
"""Test interval that qualifies (price within flex threshold from minimum)."""
|
||||||
|
# Daily min = 10 ct, flex = 15% → accepts up to 11.5 ct
|
||||||
|
criteria = TibberPricesIntervalCriteria(
|
||||||
|
ref_price=10.0, # Daily minimum
|
||||||
|
avg_price=20.0,
|
||||||
|
flex=0.15, # 15% flexibility
|
||||||
|
min_distance_from_avg=0.0,
|
||||||
|
reverse_sort=False, # Best Price mode
|
||||||
|
)
|
||||||
|
|
||||||
|
# Price 11.0 ct is within 10 + (10 * 0.15) = 11.5 ct
|
||||||
|
price = 11.0
|
||||||
|
|
||||||
|
in_flex, _meets_distance = check_interval_criteria(price, criteria)
|
||||||
|
|
||||||
|
assert in_flex is True, "Interval within flex threshold should pass flex check"
|
||||||
|
|
||||||
|
def test_interval_outside_flex_threshold(self) -> None:
|
||||||
|
"""Test interval that fails (price outside flex threshold from minimum)."""
|
||||||
|
criteria = TibberPricesIntervalCriteria(
|
||||||
|
ref_price=10.0, # Daily minimum
|
||||||
|
avg_price=20.0,
|
||||||
|
flex=0.15, # 15% flexibility
|
||||||
|
min_distance_from_avg=0.0,
|
||||||
|
reverse_sort=False, # Best Price mode
|
||||||
|
)
|
||||||
|
|
||||||
|
# Price 12.0 ct is outside 10 + (10 * 0.15) = 11.5 ct
|
||||||
|
price = 12.0
|
||||||
|
|
||||||
|
in_flex, _meets_distance = check_interval_criteria(price, criteria)
|
||||||
|
|
||||||
|
assert in_flex is False, "Interval outside flex threshold should fail flex check"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestFlexFilterPeakPrice:
|
||||||
|
"""Test flex filter logic for Peak Price (reverse_sort=True)."""
|
||||||
|
|
||||||
|
def test_interval_within_flex_threshold(self) -> None:
|
||||||
|
"""Test interval that qualifies (price within flex threshold from maximum)."""
|
||||||
|
# Daily max = 50 ct, flex = 20% → accepts down to 40 ct
|
||||||
|
# NOTE: flex is passed as POSITIVE 0.20, not negative!
|
||||||
|
criteria = TibberPricesIntervalCriteria(
|
||||||
|
ref_price=50.0, # Daily maximum
|
||||||
|
avg_price=30.0,
|
||||||
|
flex=0.20, # 20% flexibility (positive internally!)
|
||||||
|
min_distance_from_avg=0.0,
|
||||||
|
reverse_sort=True, # Peak Price mode
|
||||||
|
)
|
||||||
|
|
||||||
|
# Price 45 ct is within 50 - (50 * 0.20) = 40 ct threshold
|
||||||
|
price = 45.0
|
||||||
|
|
||||||
|
in_flex, _meets_distance = check_interval_criteria(price, criteria)
|
||||||
|
|
||||||
|
assert in_flex is True, "Interval within flex threshold should pass flex check"
|
||||||
|
|
||||||
|
def test_interval_outside_flex_threshold(self) -> None:
|
||||||
|
"""Test interval that fails (price outside flex threshold from maximum)."""
|
||||||
|
criteria = TibberPricesIntervalCriteria(
|
||||||
|
ref_price=50.0, # Daily maximum
|
||||||
|
avg_price=30.0,
|
||||||
|
flex=0.20, # 20% flexibility (positive internally!)
|
||||||
|
min_distance_from_avg=0.0,
|
||||||
|
reverse_sort=True, # Peak Price mode
|
||||||
|
)
|
||||||
|
|
||||||
|
# Price 38 ct is outside 50 - (50 * 0.20) = 40 ct threshold (too cheap!)
|
||||||
|
price = 38.0
|
||||||
|
|
||||||
|
in_flex, _meets_distance = check_interval_criteria(price, criteria)
|
||||||
|
|
||||||
|
assert in_flex is False, "Interval outside flex threshold should fail flex check"
|
||||||
|
|
||||||
|
def test_regression_bug_peak_price_sign_convention(self) -> None:
|
||||||
|
"""
|
||||||
|
Regression test for Peak Price Sign Convention Bug (Nov 2025).
|
||||||
|
|
||||||
|
Bug: When flex was passed as negative value (e.g., -0.20 for peak price),
|
||||||
|
the flex filter would reject ALL intervals because:
|
||||||
|
- User-facing config: peak_price_flex = -20% (negative sign convention)
|
||||||
|
- Expected internal: 0.20 (positive, with reverse_sort=True for direction)
|
||||||
|
- Broken behavior: Used -0.20 directly → math was wrong
|
||||||
|
|
||||||
|
Additionally, there was a redundant condition that blocked peak prices:
|
||||||
|
if reverse_sort:
|
||||||
|
in_flex = price >= ref_price + (ref_price * flex)
|
||||||
|
and price <= ref_price # ← This was the bug!
|
||||||
|
|
||||||
|
This test ensures:
|
||||||
|
1. Negative flex values are normalized to positive (abs())
|
||||||
|
2. No redundant conditions block valid peak price intervals
|
||||||
|
"""
|
||||||
|
# User-facing: -20%, internally normalized to +0.20
|
||||||
|
criteria = TibberPricesIntervalCriteria(
|
||||||
|
ref_price=50.0, # Daily maximum
|
||||||
|
avg_price=30.0,
|
||||||
|
flex=0.20, # After normalization: abs(-0.20) = 0.20
|
||||||
|
min_distance_from_avg=0.0,
|
||||||
|
reverse_sort=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Price exactly at threshold: 50 - (50 * 0.20) = 40 ct
|
||||||
|
price_at_threshold = 40.0
|
||||||
|
in_flex, _ = check_interval_criteria(price_at_threshold, criteria)
|
||||||
|
assert in_flex is True, "Boundary case should pass after normalization fix"
|
||||||
|
|
||||||
|
# Price within threshold: 45 ct
|
||||||
|
price_within = 45.0
|
||||||
|
in_flex, _ = check_interval_criteria(price_within, criteria)
|
||||||
|
assert in_flex is True, "Price within threshold should pass"
|
||||||
|
|
||||||
|
# Price outside threshold (too cheap): 38 ct
|
||||||
|
price_outside = 38.0
|
||||||
|
in_flex, _ = check_interval_criteria(price_outside, criteria)
|
||||||
|
assert in_flex is False, "Price outside threshold should fail"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestMinDistanceFilter:
|
||||||
|
"""Test min_distance_from_avg filter logic."""
|
||||||
|
|
||||||
|
def test_best_price_below_average(self) -> None:
|
||||||
|
"""Test Best Price interval below average (passes min_distance check)."""
|
||||||
|
criteria = TibberPricesIntervalCriteria(
|
||||||
|
ref_price=10.0,
|
||||||
|
avg_price=20.0,
|
||||||
|
flex=0.50, # High flex to not filter by flex
|
||||||
|
min_distance_from_avg=5.0, # 5% below average required (positive internally!)
|
||||||
|
reverse_sort=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Price 18 ct is 10% below average (20 - 18) / 20 = 0.10 → passes 5% requirement
|
||||||
|
price = 18.0
|
||||||
|
|
||||||
|
_in_flex, meets_distance = check_interval_criteria(price, criteria)
|
||||||
|
|
||||||
|
assert meets_distance is True, "Interval sufficiently below average should pass distance check"
|
||||||
|
|
||||||
|
def test_best_price_too_close_to_average(self) -> None:
|
||||||
|
"""Test Best Price interval too close to average (fails min_distance check)."""
|
||||||
|
criteria = TibberPricesIntervalCriteria(
|
||||||
|
ref_price=10.0,
|
||||||
|
avg_price=20.0,
|
||||||
|
flex=0.10, # Low flex (10%) to avoid dynamic scaling
|
||||||
|
min_distance_from_avg=5.0, # 5% below average required
|
||||||
|
reverse_sort=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Price 19.5 ct is only 2.5% below average → fails 5% requirement
|
||||||
|
# Threshold = 20 * (1 - 5/100) = 19.0 ct
|
||||||
|
# 19.5 ct > 19.0 ct → FAILS distance check
|
||||||
|
price = 19.5
|
||||||
|
|
||||||
|
_in_flex, meets_distance = check_interval_criteria(price, criteria)
|
||||||
|
|
||||||
|
assert meets_distance is False, "Interval too close to average should fail distance check"
|
||||||
|
|
||||||
|
def test_peak_price_above_average(self) -> None:
|
||||||
|
"""Test Peak Price interval above average (passes min_distance check)."""
|
||||||
|
criteria = TibberPricesIntervalCriteria(
|
||||||
|
ref_price=50.0,
|
||||||
|
avg_price=30.0,
|
||||||
|
flex=0.50, # High flex to not filter by flex
|
||||||
|
min_distance_from_avg=5.0, # 5% above average required (positive internally!)
|
||||||
|
reverse_sort=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Price 33 ct is 10% above average (33 - 30) / 30 = 0.10 → passes 5% requirement
|
||||||
|
price = 33.0
|
||||||
|
|
||||||
|
_in_flex, meets_distance = check_interval_criteria(price, criteria)
|
||||||
|
|
||||||
|
assert meets_distance is True, "Interval sufficiently above average should pass distance check"
|
||||||
|
|
||||||
|
def test_regression_min_distance_sign_convention(self) -> None:
|
||||||
|
"""
|
||||||
|
Regression test for min_distance sign convention (Nov 2025).
|
||||||
|
|
||||||
|
Bug: min_distance_from_avg had sign convention issues similar to flex:
|
||||||
|
- User-facing: best_price_min_distance = -5% (negative = below average)
|
||||||
|
- User-facing: peak_price_min_distance = +5% (positive = above average)
|
||||||
|
- Expected internal: 5.0 (always positive, direction from reverse_sort)
|
||||||
|
|
||||||
|
This test ensures min_distance is always normalized to positive.
|
||||||
|
"""
|
||||||
|
# Best Price: User-facing -5%, internally normalized to 5.0
|
||||||
|
criteria_best = TibberPricesIntervalCriteria(
|
||||||
|
ref_price=10.0,
|
||||||
|
avg_price=20.0,
|
||||||
|
flex=0.50,
|
||||||
|
min_distance_from_avg=5.0, # After normalization: abs(-5.0) = 5.0
|
||||||
|
reverse_sort=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 18 ct = 10% below average → passes 5% requirement
|
||||||
|
_, meets_distance = check_interval_criteria(18.0, criteria_best)
|
||||||
|
assert meets_distance is True, "Best price normalization works"
|
||||||
|
|
||||||
|
# Peak Price: User-facing +5%, internally normalized to 5.0
|
||||||
|
criteria_peak = TibberPricesIntervalCriteria(
|
||||||
|
ref_price=50.0,
|
||||||
|
avg_price=30.0,
|
||||||
|
flex=0.50,
|
||||||
|
min_distance_from_avg=5.0, # After normalization: abs(5.0) = 5.0
|
||||||
|
reverse_sort=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 33 ct = 10% above average → passes 5% requirement
|
||||||
|
_, meets_distance = check_interval_criteria(33.0, criteria_peak)
|
||||||
|
assert meets_distance is True, "Peak price normalization works"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestDynamicScaling:
|
||||||
|
"""Test dynamic scaling of min_distance when flex is high."""
|
||||||
|
|
||||||
|
def test_no_scaling_below_threshold(self) -> None:
|
||||||
|
"""Test no scaling when flex <= 20% (threshold)."""
|
||||||
|
criteria = TibberPricesIntervalCriteria(
|
||||||
|
ref_price=10.0,
|
||||||
|
avg_price=20.0,
|
||||||
|
flex=0.20, # Exactly at threshold
|
||||||
|
min_distance_from_avg=5.0,
|
||||||
|
reverse_sort=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Price at exactly 5% below average
|
||||||
|
# Threshold = 20 * (1 - 5/100) = 19.0 ct
|
||||||
|
price = 19.0
|
||||||
|
|
||||||
|
_, meets_distance = check_interval_criteria(price, criteria)
|
||||||
|
|
||||||
|
# At flex=20%, no scaling → full 5% requirement applies
|
||||||
|
assert meets_distance is True, "Boundary case should pass with no scaling"
|
||||||
|
|
||||||
|
def test_scaling_at_30_percent_flex(self) -> None:
|
||||||
|
"""Test dynamic scaling at flex=30% (scale factor ~0.75)."""
|
||||||
|
# flex=30% → excess=10% → scale_factor = 1.0 - (0.10 x 2.5) = 0.75
|
||||||
|
# adjusted_min_distance = 5.0 x 0.75 = 3.75%
|
||||||
|
criteria = TibberPricesIntervalCriteria(
|
||||||
|
ref_price=10.0,
|
||||||
|
avg_price=20.0,
|
||||||
|
flex=0.30, # 30% flex
|
||||||
|
min_distance_from_avg=5.0,
|
||||||
|
reverse_sort=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Price at 4% below average
|
||||||
|
# Original threshold: 20 * (1 - 5/100) = 19.0 ct
|
||||||
|
# Scaled threshold: 20 * (1 - 3.75/100) = 19.25 ct
|
||||||
|
price = 19.2 # 4% below average
|
||||||
|
|
||||||
|
_, meets_distance = check_interval_criteria(price, criteria)
|
||||||
|
|
||||||
|
# With scaling, 4% below average passes (scaled requirement: 3.75%)
|
||||||
|
assert meets_distance is True, "Dynamic scaling should relax requirement"
|
||||||
|
|
||||||
|
def test_scaling_at_50_percent_flex(self) -> None:
|
||||||
|
"""Test maximum scaling at flex=50% (scale factor 0.25)."""
|
||||||
|
# flex=50% → excess=30% → scale_factor = max(0.25, 1.0 - 0.75) = 0.25
|
||||||
|
# adjusted_min_distance = 5.0 x 0.25 = 1.25%
|
||||||
|
criteria = TibberPricesIntervalCriteria(
|
||||||
|
ref_price=10.0,
|
||||||
|
avg_price=20.0,
|
||||||
|
flex=0.50, # Maximum safe flex
|
||||||
|
min_distance_from_avg=5.0,
|
||||||
|
reverse_sort=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Price at 2% below average (would fail without scaling)
|
||||||
|
# Original threshold: 20 * (1 - 5/100) = 19.0 ct
|
||||||
|
# Scaled threshold: 20 * (1 - 1.25/100) = 19.75 ct
|
||||||
|
price = 19.6 # 2% below average
|
||||||
|
|
||||||
|
_, meets_distance = check_interval_criteria(price, criteria)
|
||||||
|
|
||||||
|
# With maximum scaling, 2% below average passes (scaled requirement: 1.25%)
|
||||||
|
assert meets_distance is True, "Maximum scaling should heavily relax requirement"
|
||||||
|
|
||||||
|
def test_scaling_never_below_25_percent(self) -> None:
|
||||||
|
"""Test that scale factor never goes below 0.25 (25%)."""
|
||||||
|
# Even with unrealistically high flex, min 25% of min_distance is enforced
|
||||||
|
criteria = TibberPricesIntervalCriteria(
|
||||||
|
ref_price=10.0,
|
||||||
|
avg_price=20.0,
|
||||||
|
flex=0.80, # Unrealistically high flex (would be capped elsewhere)
|
||||||
|
min_distance_from_avg=5.0,
|
||||||
|
reverse_sort=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Minimum scaled distance: 5.0 x 0.25 = 1.25%
|
||||||
|
# Threshold: 20 * (1 - 1.25/100) = 19.75 ct
|
||||||
|
price_fail = 19.8 # 1% below average (fails even with max scaling)
|
||||||
|
price_pass = 19.7 # 1.5% below average (passes)
|
||||||
|
|
||||||
|
_, meets_distance_fail = check_interval_criteria(price_fail, criteria)
|
||||||
|
_, meets_distance_pass = check_interval_criteria(price_pass, criteria)
|
||||||
|
|
||||||
|
assert meets_distance_fail is False, "Below minimum scaled threshold should fail"
|
||||||
|
assert meets_distance_pass is True, "Above minimum scaled threshold should pass"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestBoundaryConditions:
|
||||||
|
"""Test boundary and edge cases."""
|
||||||
|
|
||||||
|
def test_price_exactly_at_ref_price_best(self) -> None:
|
||||||
|
"""Test Best Price: interval exactly at reference price (daily minimum)."""
|
||||||
|
criteria = TibberPricesIntervalCriteria(
|
||||||
|
ref_price=10.0,
|
||||||
|
avg_price=20.0,
|
||||||
|
flex=0.15,
|
||||||
|
min_distance_from_avg=0.0,
|
||||||
|
reverse_sort=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Price exactly at daily minimum
|
||||||
|
price = 10.0
|
||||||
|
|
||||||
|
in_flex, _ = check_interval_criteria(price, criteria)
|
||||||
|
|
||||||
|
assert in_flex is True, "Price at reference should pass"
|
||||||
|
|
||||||
|
def test_price_exactly_at_ref_price_peak(self) -> None:
|
||||||
|
"""Test Peak Price: interval exactly at reference price (daily maximum)."""
|
||||||
|
criteria = TibberPricesIntervalCriteria(
|
||||||
|
ref_price=50.0,
|
||||||
|
avg_price=30.0,
|
||||||
|
flex=0.20,
|
||||||
|
min_distance_from_avg=0.0,
|
||||||
|
reverse_sort=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Price exactly at daily maximum
|
||||||
|
price = 50.0
|
||||||
|
|
||||||
|
in_flex, _ = check_interval_criteria(price, criteria)
|
||||||
|
|
||||||
|
assert in_flex is True, "Price at reference should pass"
|
||||||
|
|
||||||
|
def test_price_exactly_at_flex_threshold_best(self) -> None:
|
||||||
|
"""Test Best Price: interval exactly at flex threshold."""
|
||||||
|
criteria = TibberPricesIntervalCriteria(
|
||||||
|
ref_price=10.0,
|
||||||
|
avg_price=20.0,
|
||||||
|
flex=0.15, # 15% → accepts up to 11.5 ct
|
||||||
|
min_distance_from_avg=0.0,
|
||||||
|
reverse_sort=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Price exactly at threshold: 10 + (10 * 0.15) = 11.5 ct
|
||||||
|
price = 11.5
|
||||||
|
|
||||||
|
in_flex, _ = check_interval_criteria(price, criteria)
|
||||||
|
|
||||||
|
assert in_flex is True, "Price at flex threshold should pass"
|
||||||
|
|
||||||
|
def test_price_exactly_at_flex_threshold_peak(self) -> None:
|
||||||
|
"""Test Peak Price: interval exactly at flex threshold."""
|
||||||
|
criteria = TibberPricesIntervalCriteria(
|
||||||
|
ref_price=50.0,
|
||||||
|
avg_price=30.0,
|
||||||
|
flex=0.20, # 20% → accepts down to 40 ct
|
||||||
|
min_distance_from_avg=0.0,
|
||||||
|
reverse_sort=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Price exactly at threshold: 50 - (50 * 0.20) = 40 ct
|
||||||
|
price = 40.0
|
||||||
|
|
||||||
|
in_flex, _ = check_interval_criteria(price, criteria)
|
||||||
|
|
||||||
|
assert in_flex is True, "Price at flex threshold should pass"
|
||||||
|
|
||||||
|
def test_price_one_cent_outside_flex_threshold_best(self) -> None:
|
||||||
|
"""Test Best Price: interval one cent outside flex threshold."""
|
||||||
|
criteria = TibberPricesIntervalCriteria(
|
||||||
|
ref_price=10.0,
|
||||||
|
avg_price=20.0,
|
||||||
|
flex=0.15, # Accepts up to 11.5 ct
|
||||||
|
min_distance_from_avg=0.0,
|
||||||
|
reverse_sort=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Price one cent over threshold
|
||||||
|
price = 11.51
|
||||||
|
|
||||||
|
in_flex, _ = check_interval_criteria(price, criteria)
|
||||||
|
|
||||||
|
assert in_flex is False, "Price over threshold should fail"
|
||||||
|
|
||||||
|
def test_price_one_cent_outside_flex_threshold_peak(self) -> None:
|
||||||
|
"""Test Peak Price: interval one cent outside flex threshold."""
|
||||||
|
criteria = TibberPricesIntervalCriteria(
|
||||||
|
ref_price=50.0,
|
||||||
|
avg_price=30.0,
|
||||||
|
flex=0.20, # Accepts down to 40 ct
|
||||||
|
min_distance_from_avg=0.0,
|
||||||
|
reverse_sort=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Price one cent below threshold (too cheap)
|
||||||
|
price = 39.99
|
||||||
|
|
||||||
|
in_flex, _ = check_interval_criteria(price, criteria)
|
||||||
|
|
||||||
|
assert in_flex is False, "Price below threshold should fail"
|
||||||
|
|
||||||
|
def test_zero_flex(self) -> None:
|
||||||
|
"""Test with zero flexibility (only exact reference price passes)."""
|
||||||
|
criteria = TibberPricesIntervalCriteria(
|
||||||
|
ref_price=10.0,
|
||||||
|
avg_price=20.0,
|
||||||
|
flex=0.0, # Zero flexibility
|
||||||
|
min_distance_from_avg=0.0,
|
||||||
|
reverse_sort=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Only exact reference price should pass
|
||||||
|
price_exact = 10.0
|
||||||
|
price_above = 10.01
|
||||||
|
|
||||||
|
in_flex_exact, _ = check_interval_criteria(price_exact, criteria)
|
||||||
|
in_flex_above, _ = check_interval_criteria(price_above, criteria)
|
||||||
|
|
||||||
|
assert in_flex_exact is True, "Exact price should pass with zero flex"
|
||||||
|
assert in_flex_above is False, "Above reference should fail with zero flex"
|
||||||
|
|
||||||
|
def test_zero_min_distance(self) -> None:
|
||||||
|
"""Test with zero min_distance (any price passes distance check)."""
|
||||||
|
criteria = TibberPricesIntervalCriteria(
|
||||||
|
ref_price=10.0,
|
||||||
|
avg_price=20.0,
|
||||||
|
flex=0.50,
|
||||||
|
min_distance_from_avg=0.0, # Zero min_distance
|
||||||
|
reverse_sort=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Price exactly at average (would normally fail distance check)
|
||||||
|
price = 20.0
|
||||||
|
|
||||||
|
_, meets_distance = check_interval_criteria(price, criteria)
|
||||||
|
|
||||||
|
# With zero min_distance, any price passes distance check
|
||||||
|
assert meets_distance is True, "Zero min_distance should accept all prices"
|
||||||
|
|
||||||
|
def test_price_exactly_at_average_best(self) -> None:
|
||||||
|
"""Test Best Price: interval exactly at average (fails with min_distance>0)."""
|
||||||
|
criteria = TibberPricesIntervalCriteria(
|
||||||
|
ref_price=10.0,
|
||||||
|
avg_price=20.0,
|
||||||
|
flex=0.50,
|
||||||
|
min_distance_from_avg=5.0, # Requires 5% below average
|
||||||
|
reverse_sort=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Price exactly at average
|
||||||
|
price = 20.0
|
||||||
|
|
||||||
|
_, meets_distance = check_interval_criteria(price, criteria)
|
||||||
|
|
||||||
|
assert meets_distance is False, "Price at average should fail distance check"
|
||||||
|
|
||||||
|
def test_price_exactly_at_average_peak(self) -> None:
|
||||||
|
"""Test Peak Price: interval exactly at average (fails with min_distance>0)."""
|
||||||
|
criteria = TibberPricesIntervalCriteria(
|
||||||
|
ref_price=50.0,
|
||||||
|
avg_price=30.0,
|
||||||
|
flex=0.50,
|
||||||
|
min_distance_from_avg=5.0, # Requires 5% above average
|
||||||
|
reverse_sort=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Price exactly at average
|
||||||
|
price = 30.0
|
||||||
|
|
||||||
|
_, meets_distance = check_interval_criteria(price, criteria)
|
||||||
|
|
||||||
|
assert meets_distance is False, "Price at average should fail distance check"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestCombinedFilters:
|
||||||
|
"""Test interaction between flex and min_distance filters."""
|
||||||
|
|
||||||
|
def test_passes_flex_fails_distance(self) -> None:
|
||||||
|
"""Test interval that passes flex but fails min_distance."""
|
||||||
|
# Setup: We need flex threshold WIDER than distance threshold
|
||||||
|
# Use flex <= 20% to avoid dynamic scaling interference
|
||||||
|
criteria = TibberPricesIntervalCriteria(
|
||||||
|
ref_price=10.0,
|
||||||
|
avg_price=13.0, # Closer average makes distance threshold tighter
|
||||||
|
flex=0.20, # Flex threshold: 10 + (10 * 0.20) = 12 ct
|
||||||
|
min_distance_from_avg=10.0, # Distance threshold: 13 * (1 - 10/100) = 11.7 ct
|
||||||
|
reverse_sort=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Price 11.8 ct: passes flex (11.8 <= 12) but fails distance (11.8 > 11.7)
|
||||||
|
price = 11.8
|
||||||
|
|
||||||
|
in_flex, meets_distance = check_interval_criteria(price, criteria)
|
||||||
|
|
||||||
|
assert in_flex is True, "Should pass flex (within 12 ct threshold)"
|
||||||
|
assert meets_distance is False, "Should fail distance (above 11.7 ct threshold)"
|
||||||
|
|
||||||
|
def test_fails_flex_passes_distance(self) -> None:
|
||||||
|
"""Test interval that fails flex but passes min_distance."""
|
||||||
|
criteria = TibberPricesIntervalCriteria(
|
||||||
|
ref_price=10.0, # Daily min
|
||||||
|
avg_price=20.0,
|
||||||
|
flex=0.15, # Low flex - accepts up to 11.5 ct
|
||||||
|
min_distance_from_avg=5.0, # Requires 5% below average (19 ct or less)
|
||||||
|
reverse_sort=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Price 12 ct fails flex (> 11.5 ct) but passes distance (40% below avg)
|
||||||
|
price = 12.0
|
||||||
|
|
||||||
|
in_flex, meets_distance = check_interval_criteria(price, criteria)
|
||||||
|
|
||||||
|
assert in_flex is False, "Should fail flex (outside 11.5 ct threshold)"
|
||||||
|
assert meets_distance is True, "Should pass distance (well below average)"
|
||||||
|
|
||||||
|
def test_both_filters_pass(self) -> None:
|
||||||
|
"""Test interval that passes both filters."""
|
||||||
|
criteria = TibberPricesIntervalCriteria(
|
||||||
|
ref_price=10.0,
|
||||||
|
avg_price=20.0,
|
||||||
|
flex=0.20, # Accepts up to 12 ct
|
||||||
|
min_distance_from_avg=5.0, # Requires 5% below average (19 ct or less)
|
||||||
|
reverse_sort=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Price 11 ct passes both: < 12 ct (flex) and < 19 ct (distance)
|
||||||
|
price = 11.0
|
||||||
|
|
||||||
|
in_flex, meets_distance = check_interval_criteria(price, criteria)
|
||||||
|
|
||||||
|
assert in_flex is True, "Should pass flex"
|
||||||
|
assert meets_distance is True, "Should pass distance"
|
||||||
|
|
||||||
|
def test_both_filters_fail(self) -> None:
|
||||||
|
"""Test interval that fails both filters."""
|
||||||
|
criteria = TibberPricesIntervalCriteria(
|
||||||
|
ref_price=10.0,
|
||||||
|
avg_price=20.0,
|
||||||
|
flex=0.15, # Accepts up to 11.5 ct
|
||||||
|
min_distance_from_avg=5.0, # Requires 5% below average (19 ct or less)
|
||||||
|
reverse_sort=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Price 19.5 ct fails both: > 11.5 ct (flex) and > 19 ct (distance)
|
||||||
|
price = 19.5
|
||||||
|
|
||||||
|
in_flex, meets_distance = check_interval_criteria(price, criteria)
|
||||||
|
|
||||||
|
assert in_flex is False, "Should fail flex"
|
||||||
|
assert meets_distance is False, "Should fail distance"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestRealWorldScenarios:
|
||||||
|
"""Test with realistic price data and configurations."""
|
||||||
|
|
||||||
|
def test_german_market_best_price(self) -> None:
|
||||||
|
"""Test Best Price with realistic German market data (Nov 2025)."""
|
||||||
|
# Real data from Nov 22, 2025: Min=0.17 ct, Avg=8.26 ct, Max=17.24 ct
|
||||||
|
criteria = TibberPricesIntervalCriteria(
|
||||||
|
ref_price=0.17, # Daily minimum (early morning)
|
||||||
|
avg_price=8.26, # Daily average
|
||||||
|
flex=0.15, # 15% default flex
|
||||||
|
min_distance_from_avg=5.0, # -5% below average (user-facing)
|
||||||
|
reverse_sort=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Calculate thresholds:
|
||||||
|
# Flex threshold: 0.17 + (0.17 * 0.15) = 0.17 + 0.0255 = 0.1955 ct
|
||||||
|
# Distance threshold: 8.26 * (1 - 5/100) = 8.26 * 0.95 = 7.847 ct
|
||||||
|
|
||||||
|
# Price scenarios
|
||||||
|
price_at_min = 0.17 # Should pass both (at minimum)
|
||||||
|
price_within_flex = 0.19 # Should pass flex (< 0.1955)
|
||||||
|
price_too_high = 5.0 # Should fail flex (> 0.1955), but pass distance (< 7.847)
|
||||||
|
|
||||||
|
in_flex_min, meets_dist_min = check_interval_criteria(price_at_min, criteria)
|
||||||
|
in_flex_within, meets_dist_within = check_interval_criteria(price_within_flex, criteria)
|
||||||
|
in_flex_high, meets_dist_high = check_interval_criteria(price_too_high, criteria)
|
||||||
|
|
||||||
|
assert in_flex_min is True, "Minimum price should pass flex"
|
||||||
|
assert meets_dist_min is True, "Minimum price should pass distance"
|
||||||
|
|
||||||
|
assert in_flex_within is True, "0.19 ct should pass flex (< 0.1955)"
|
||||||
|
assert meets_dist_within is True, "0.19 ct should pass distance (< 7.847)"
|
||||||
|
|
||||||
|
assert in_flex_high is False, "5 ct should fail flex (way above 0.1955 threshold)"
|
||||||
|
assert meets_dist_high is True, "5 ct should pass distance (< 7.847)"
|
||||||
|
|
||||||
|
def test_german_market_peak_price(self) -> None:
|
||||||
|
"""Test Peak Price with realistic German market data (Nov 2025)."""
|
||||||
|
# Real data: Min=0.17 ct, Avg=8.26 ct, Max=17.24 ct
|
||||||
|
criteria = TibberPricesIntervalCriteria(
|
||||||
|
ref_price=17.24, # Daily maximum (evening peak)
|
||||||
|
avg_price=8.26, # Daily average
|
||||||
|
flex=0.20, # 20% default flex (user-facing: -20%)
|
||||||
|
min_distance_from_avg=5.0, # +5% above average (user-facing)
|
||||||
|
reverse_sort=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Calculate thresholds:
|
||||||
|
# Flex threshold: 17.24 - (17.24 * 0.20) = 17.24 - 3.448 = 13.792 ct
|
||||||
|
# Distance threshold: 8.26 * (1 + 5/100) = 8.26 * 1.05 = 8.673 ct
|
||||||
|
|
||||||
|
# Price scenarios
|
||||||
|
price_at_max = 17.24 # Should pass both (at maximum)
|
||||||
|
price_within_flex = 14.0 # Should pass flex (> 13.792)
|
||||||
|
price_too_low = 10.0 # Should fail flex (< 13.792)
|
||||||
|
|
||||||
|
in_flex_max, meets_dist_max = check_interval_criteria(price_at_max, criteria)
|
||||||
|
in_flex_within, meets_dist_within = check_interval_criteria(price_within_flex, criteria)
|
||||||
|
in_flex_low, meets_dist_low = check_interval_criteria(price_too_low, criteria)
|
||||||
|
|
||||||
|
assert in_flex_max is True, "Maximum price should pass flex"
|
||||||
|
assert meets_dist_max is True, "Maximum price should pass distance"
|
||||||
|
|
||||||
|
assert in_flex_within is True, "14 ct should pass flex (> 13.792)"
|
||||||
|
assert meets_dist_within is True, "14 ct should pass distance (> 8.673)"
|
||||||
|
|
||||||
|
assert in_flex_low is False, "10 ct should fail flex (< 13.792 threshold)"
|
||||||
|
# 10 ct is still above average (8.26), so should pass distance
|
||||||
|
assert meets_dist_low is True, "10 ct should pass distance (> 8.673)"
|
||||||
|
|
||||||
|
def test_negative_prices(self) -> None:
|
||||||
|
"""Test with negative prices (wind/solar surplus scenarios)."""
|
||||||
|
# Scenario: Lots of renewable energy, prices go negative
|
||||||
|
criteria = TibberPricesIntervalCriteria(
|
||||||
|
ref_price=-5.0, # Daily minimum (negative!)
|
||||||
|
avg_price=3.0, # Daily average (some hours still positive)
|
||||||
|
flex=0.20, # 20% flex
|
||||||
|
min_distance_from_avg=5.0,
|
||||||
|
reverse_sort=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Price at -5 ct (minimum, negative)
|
||||||
|
# Flex threshold: -5 + abs(-5 * 0.20) = -5 + 1 = -4 ct
|
||||||
|
price_at_min = -5.0
|
||||||
|
price_within_flex = -4.5
|
||||||
|
|
||||||
|
in_flex_min, _ = check_interval_criteria(price_at_min, criteria)
|
||||||
|
in_flex_within, _ = check_interval_criteria(price_within_flex, criteria)
|
||||||
|
|
||||||
|
assert in_flex_min is True, "Negative minimum should pass"
|
||||||
|
assert in_flex_within is True, "Within flex of negative min should pass"
|
||||||
381
tests/test_peak_price_e2e.py
Normal file
381
tests/test_peak_price_e2e.py
Normal file
|
|
@ -0,0 +1,381 @@
|
||||||
|
"""
|
||||||
|
End-to-End Tests for Peak Price Period Generation (Nov 2025 Bug Fix).
|
||||||
|
|
||||||
|
These tests validate that the sign convention bug fix works correctly:
|
||||||
|
- Bug: Negative flex (-20%) wasn't normalized → 100% FLEX filtering
|
||||||
|
- Fix: abs() normalization in periods.py + removed redundant condition
|
||||||
|
|
||||||
|
Test coverage matches manual testing checklist:
|
||||||
|
1. ✅ Peak periods generate (not 0)
|
||||||
|
2. ✅ FLEX filter stats reasonable (~40-50%, not 100%)
|
||||||
|
3. ✅ Relaxation succeeds at reasonable flex (not maxed at 50%)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from unittest.mock import Mock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from custom_components.tibber_prices.coordinator.period_handlers import (
|
||||||
|
TibberPricesPeriodConfig,
|
||||||
|
calculate_periods_with_relaxation,
|
||||||
|
)
|
||||||
|
from custom_components.tibber_prices.coordinator.time_service import (
|
||||||
|
TibberPricesTimeService,
|
||||||
|
)
|
||||||
|
from homeassistant.util import dt as dt_util
|
||||||
|
|
||||||
|
|
||||||
|
def _create_realistic_intervals() -> list[dict]:
|
||||||
|
"""
|
||||||
|
Create realistic test data matching German market Nov 22, 2025.
|
||||||
|
|
||||||
|
Pattern: Morning peak (6-9h), midday low (9-15h), evening moderate (15-24h).
|
||||||
|
Daily stats: Min=30.44ct, Avg=33.26ct, Max=36.03ct
|
||||||
|
"""
|
||||||
|
base_time = dt_util.parse_datetime("2025-11-22T00:00:00+01:00")
|
||||||
|
assert base_time is not None
|
||||||
|
|
||||||
|
daily_min, daily_avg, daily_max = 0.3044, 0.3326, 0.3603
|
||||||
|
|
||||||
|
def _create_interval(hour: int, minute: int, price: float, level: str, rating: str) -> dict:
|
||||||
|
"""Create a single interval dict."""
|
||||||
|
return {
|
||||||
|
"startsAt": base_time.replace(hour=hour, minute=minute), # datetime object
|
||||||
|
"total": price,
|
||||||
|
"level": level,
|
||||||
|
"rating_level": rating,
|
||||||
|
"_original_price": price,
|
||||||
|
"trailing_avg_24h": daily_avg,
|
||||||
|
"daily_min": daily_min,
|
||||||
|
"daily_avg": daily_avg,
|
||||||
|
"daily_max": daily_max,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Build all intervals as list comprehensions
|
||||||
|
intervals = []
|
||||||
|
|
||||||
|
# Overnight (00:00-06:00) - NORMAL
|
||||||
|
intervals.extend(
|
||||||
|
[_create_interval(hour, minute, 0.318, "NORMAL", "NORMAL") for hour in range(6) for minute in [0, 15, 30, 45]]
|
||||||
|
)
|
||||||
|
|
||||||
|
# Morning spike (06:00-09:00) - EXPENSIVE
|
||||||
|
intervals.extend(
|
||||||
|
[
|
||||||
|
_create_interval(
|
||||||
|
hour,
|
||||||
|
minute,
|
||||||
|
price := 0.33 + (hour - 6) * 0.01,
|
||||||
|
"EXPENSIVE" if price > 0.34 else "NORMAL",
|
||||||
|
"HIGH" if price > 0.35 else "NORMAL",
|
||||||
|
)
|
||||||
|
for hour in range(6, 9)
|
||||||
|
for minute in [0, 15, 30, 45]
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
# Midday low (09:00-15:00) - CHEAP
|
||||||
|
intervals.extend(
|
||||||
|
[
|
||||||
|
_create_interval(hour, minute, 0.305 + (hour - 12) * 0.002, "CHEAP", "LOW")
|
||||||
|
for hour in range(9, 15)
|
||||||
|
for minute in [0, 15, 30, 45]
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
# Evening moderate (15:00-24:00) - NORMAL to EXPENSIVE
|
||||||
|
intervals.extend(
|
||||||
|
[
|
||||||
|
_create_interval(
|
||||||
|
hour,
|
||||||
|
minute,
|
||||||
|
price := 0.32 + (hour - 15) * 0.005,
|
||||||
|
"EXPENSIVE" if price > 0.34 else "NORMAL",
|
||||||
|
"HIGH" if price > 0.35 else "NORMAL",
|
||||||
|
)
|
||||||
|
for hour in range(15, 24)
|
||||||
|
for minute in [0, 15, 30, 45]
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
return intervals
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestPeakPriceGenerationWorks:
|
||||||
|
"""Validate that peak price periods generate successfully after bug fix."""
|
||||||
|
|
||||||
|
def test_peak_periods_generate_successfully(self) -> None:
|
||||||
|
"""
|
||||||
|
✅ PRIMARY TEST: Peak periods generate (not 0 like the bug).
|
||||||
|
|
||||||
|
Bug: 192/192 intervals filtered by FLEX (100%) → 0 periods
|
||||||
|
Fix: Negative flex normalized → periods generate
|
||||||
|
"""
|
||||||
|
intervals = _create_realistic_intervals()
|
||||||
|
|
||||||
|
# Mock coordinator (minimal setup)
|
||||||
|
mock_coordinator = Mock()
|
||||||
|
mock_coordinator.config_entry = Mock()
|
||||||
|
time_service = TibberPricesTimeService(mock_coordinator)
|
||||||
|
# Mock now() to return test date
|
||||||
|
test_time = dt_util.parse_datetime("2025-11-22T12:00:00+01:00")
|
||||||
|
time_service.now = Mock(return_value=test_time)
|
||||||
|
|
||||||
|
# Create config with normalized positive flex (simulating fix)
|
||||||
|
config = TibberPricesPeriodConfig(
|
||||||
|
flex=0.20, # 20% positive (after abs() normalization)
|
||||||
|
min_distance_from_avg=5.0,
|
||||||
|
min_period_length=30,
|
||||||
|
reverse_sort=True, # Peak price mode
|
||||||
|
)
|
||||||
|
|
||||||
|
# Calculate periods with relaxation
|
||||||
|
result, _ = calculate_periods_with_relaxation(
|
||||||
|
intervals,
|
||||||
|
config=config,
|
||||||
|
enable_relaxation=True,
|
||||||
|
min_periods=2,
|
||||||
|
max_relaxation_attempts=11,
|
||||||
|
should_show_callback=lambda _: True, # Allow all levels
|
||||||
|
time=time_service,
|
||||||
|
)
|
||||||
|
|
||||||
|
periods = result.get("periods", [])
|
||||||
|
|
||||||
|
# Bug validation: periods found (not 0)
|
||||||
|
assert len(periods) > 0, "Peak periods should generate after bug fix"
|
||||||
|
assert 2 <= len(periods) <= 5, f"Expected 2-5 periods, got {len(periods)}"
|
||||||
|
|
||||||
|
def test_negative_flex_normalization_effect(self) -> None:
|
||||||
|
"""
|
||||||
|
✅ TEST: Positive flex (normalized) produces periods.
|
||||||
|
|
||||||
|
Bug: Would use negative flex (-20%) directly in math → 100% FLEX filter
|
||||||
|
Fix: abs() ensures positive flex → reasonable filtering
|
||||||
|
"""
|
||||||
|
intervals = _create_realistic_intervals()
|
||||||
|
|
||||||
|
mock_coordinator = Mock()
|
||||||
|
mock_coordinator.config_entry = Mock()
|
||||||
|
time_service = TibberPricesTimeService(mock_coordinator)
|
||||||
|
# Mock now() to return test date
|
||||||
|
test_time = dt_util.parse_datetime("2025-11-22T12:00:00+01:00")
|
||||||
|
time_service.now = Mock(return_value=test_time)
|
||||||
|
|
||||||
|
# Test with positive flex (simulates normalized result)
|
||||||
|
config_positive = TibberPricesPeriodConfig(
|
||||||
|
flex=0.20, # Positive after normalization
|
||||||
|
min_distance_from_avg=5.0,
|
||||||
|
min_period_length=30,
|
||||||
|
reverse_sort=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
result_pos, _ = calculate_periods_with_relaxation(
|
||||||
|
intervals,
|
||||||
|
config=config_positive,
|
||||||
|
enable_relaxation=True,
|
||||||
|
min_periods=2,
|
||||||
|
max_relaxation_attempts=11,
|
||||||
|
should_show_callback=lambda _: True,
|
||||||
|
time=time_service,
|
||||||
|
)
|
||||||
|
|
||||||
|
periods_pos = result_pos.get("periods", [])
|
||||||
|
|
||||||
|
# With normalized positive flex, should find periods
|
||||||
|
assert len(periods_pos) >= 2, f"Should find periods with positive flex, got {len(periods_pos)}"
|
||||||
|
|
||||||
|
def test_periods_contain_high_prices(self) -> None:
|
||||||
|
"""
|
||||||
|
✅ TEST: Peak periods contain high prices (not cheap ones).
|
||||||
|
|
||||||
|
Validates periods include expensive intervals, not cheap ones.
|
||||||
|
"""
|
||||||
|
intervals = _create_realistic_intervals()
|
||||||
|
|
||||||
|
mock_coordinator = Mock()
|
||||||
|
mock_coordinator.config_entry = Mock()
|
||||||
|
time_service = TibberPricesTimeService(mock_coordinator)
|
||||||
|
# Mock now() to return test date
|
||||||
|
test_time = dt_util.parse_datetime("2025-11-22T12:00:00+01:00")
|
||||||
|
time_service.now = Mock(return_value=test_time)
|
||||||
|
|
||||||
|
config = TibberPricesPeriodConfig(
|
||||||
|
flex=0.20,
|
||||||
|
min_distance_from_avg=5.0,
|
||||||
|
min_period_length=30,
|
||||||
|
reverse_sort=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
result, _ = calculate_periods_with_relaxation(
|
||||||
|
intervals,
|
||||||
|
config=config,
|
||||||
|
enable_relaxation=True,
|
||||||
|
min_periods=2,
|
||||||
|
max_relaxation_attempts=11,
|
||||||
|
should_show_callback=lambda _: True,
|
||||||
|
time=time_service,
|
||||||
|
)
|
||||||
|
|
||||||
|
periods = result.get("periods", [])
|
||||||
|
|
||||||
|
daily_min = intervals[0]["daily_min"]
|
||||||
|
|
||||||
|
# Check period averages are NOT near daily minimum
|
||||||
|
for period in periods:
|
||||||
|
period_avg = period.get("price_avg", 0)
|
||||||
|
assert period_avg > daily_min * 1.05, (
|
||||||
|
f"Peak period has too low avg: {period_avg:.4f} vs daily_min={daily_min:.4f}"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_relaxation_works_at_reasonable_flex(self) -> None:
|
||||||
|
"""
|
||||||
|
✅ TEST: Relaxation succeeds without maxing flex at 50%.
|
||||||
|
|
||||||
|
Validates relaxation finds periods at reasonable flex levels.
|
||||||
|
"""
|
||||||
|
intervals = _create_realistic_intervals()
|
||||||
|
|
||||||
|
mock_coordinator = Mock()
|
||||||
|
mock_coordinator.config_entry = Mock()
|
||||||
|
time_service = TibberPricesTimeService(mock_coordinator)
|
||||||
|
# Mock now() to return test date
|
||||||
|
test_time = dt_util.parse_datetime("2025-11-22T12:00:00+01:00")
|
||||||
|
time_service.now = Mock(return_value=test_time)
|
||||||
|
|
||||||
|
# Lower flex to trigger relaxation
|
||||||
|
config = TibberPricesPeriodConfig(
|
||||||
|
flex=0.15, # 15% - may need relaxation
|
||||||
|
min_distance_from_avg=5.0,
|
||||||
|
min_period_length=30,
|
||||||
|
reverse_sort=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
result, relaxation_meta = calculate_periods_with_relaxation(
|
||||||
|
intervals,
|
||||||
|
config=config,
|
||||||
|
enable_relaxation=True,
|
||||||
|
min_periods=2,
|
||||||
|
max_relaxation_attempts=11,
|
||||||
|
should_show_callback=lambda _: True,
|
||||||
|
time=time_service,
|
||||||
|
)
|
||||||
|
|
||||||
|
periods = result.get("periods", [])
|
||||||
|
|
||||||
|
# Should find periods via relaxation
|
||||||
|
assert len(periods) >= 2, "Relaxation should find periods"
|
||||||
|
|
||||||
|
# Check if relaxation was used
|
||||||
|
if "max_flex_used" in relaxation_meta:
|
||||||
|
max_flex_used = relaxation_meta["max_flex_used"]
|
||||||
|
# Bug would need ~50% flex
|
||||||
|
# Fix: reasonable flex (15-35%) is sufficient
|
||||||
|
assert max_flex_used <= 0.35, f"Flex should stay reasonable, got {max_flex_used * 100:.1f}%"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestBugRegressionValidation:
|
||||||
|
"""Regression tests for the Nov 2025 sign convention bug."""
|
||||||
|
|
||||||
|
def test_metadata_shows_reasonable_flex_used(self) -> None:
|
||||||
|
"""
|
||||||
|
✅ REGRESSION: Metadata shows flex used was reasonable (not 50%).
|
||||||
|
|
||||||
|
This indirectly validates FLEX filter didn't block everything.
|
||||||
|
"""
|
||||||
|
intervals = _create_realistic_intervals()
|
||||||
|
|
||||||
|
mock_coordinator = Mock()
|
||||||
|
mock_coordinator.config_entry = Mock()
|
||||||
|
time_service = TibberPricesTimeService(mock_coordinator)
|
||||||
|
# Mock now() to return test date
|
||||||
|
test_time = dt_util.parse_datetime("2025-11-22T12:00:00+01:00")
|
||||||
|
time_service.now = Mock(return_value=test_time)
|
||||||
|
|
||||||
|
config = TibberPricesPeriodConfig(
|
||||||
|
flex=0.20,
|
||||||
|
min_distance_from_avg=5.0,
|
||||||
|
min_period_length=30,
|
||||||
|
reverse_sort=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
result, relaxation_meta = calculate_periods_with_relaxation(
|
||||||
|
intervals,
|
||||||
|
config=config,
|
||||||
|
enable_relaxation=True,
|
||||||
|
min_periods=2,
|
||||||
|
max_relaxation_attempts=11,
|
||||||
|
should_show_callback=lambda _: True,
|
||||||
|
time=time_service,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check metadata from result
|
||||||
|
metadata = result.get("metadata", {})
|
||||||
|
config_used = metadata.get("config", {})
|
||||||
|
|
||||||
|
if "flex" in config_used:
|
||||||
|
flex_used = config_used["flex"]
|
||||||
|
# Bug would need ~50% flex to find anything
|
||||||
|
# Fix: reasonable flex (~20-30%) is sufficient
|
||||||
|
assert 0.15 <= flex_used <= 0.35, (
|
||||||
|
f"Expected flex 15-35%, got {flex_used * 100:.1f}% (Bug would require near 50%)"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Also check relaxation metadata
|
||||||
|
if "max_flex_used" in relaxation_meta:
|
||||||
|
max_flex = relaxation_meta["max_flex_used"]
|
||||||
|
assert max_flex <= 0.35, f"Max flex should be reasonable, got {max_flex * 100:.1f}%"
|
||||||
|
|
||||||
|
def test_periods_include_expensive_intervals(self) -> None:
|
||||||
|
"""
|
||||||
|
✅ REGRESSION: Peak periods include intervals near daily max.
|
||||||
|
|
||||||
|
Bug had redundant condition: price >= ref AND price <= ref
|
||||||
|
Fix: Removed redundant condition → high prices included
|
||||||
|
"""
|
||||||
|
intervals = _create_realistic_intervals()
|
||||||
|
|
||||||
|
mock_coordinator = Mock()
|
||||||
|
mock_coordinator.config_entry = Mock()
|
||||||
|
time_service = TibberPricesTimeService(mock_coordinator)
|
||||||
|
# Mock now() to return test date
|
||||||
|
test_time = dt_util.parse_datetime("2025-11-22T12:00:00+01:00")
|
||||||
|
time_service.now = Mock(return_value=test_time)
|
||||||
|
|
||||||
|
config = TibberPricesPeriodConfig(
|
||||||
|
flex=0.20,
|
||||||
|
min_distance_from_avg=5.0,
|
||||||
|
min_period_length=30,
|
||||||
|
reverse_sort=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
result, _ = calculate_periods_with_relaxation(
|
||||||
|
intervals,
|
||||||
|
config=config,
|
||||||
|
enable_relaxation=True,
|
||||||
|
min_periods=2,
|
||||||
|
max_relaxation_attempts=11,
|
||||||
|
should_show_callback=lambda _: True,
|
||||||
|
time=time_service,
|
||||||
|
)
|
||||||
|
|
||||||
|
periods = result.get("periods", [])
|
||||||
|
|
||||||
|
daily_avg = intervals[0]["daily_avg"]
|
||||||
|
daily_max = intervals[0]["daily_max"]
|
||||||
|
|
||||||
|
# At least one period should have high average
|
||||||
|
max_period_avg = max(p.get("price_avg", 0) for p in periods)
|
||||||
|
|
||||||
|
assert max_period_avg >= daily_avg * 1.05, (
|
||||||
|
f"Peak periods should have high avg: {max_period_avg:.4f} vs daily_avg={daily_avg:.4f}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check proximity to daily max
|
||||||
|
assert max_period_avg >= daily_max * 0.85, (
|
||||||
|
f"At least one period near daily_max: {max_period_avg:.4f} vs daily_max={daily_max:.4f}"
|
||||||
|
)
|
||||||
Loading…
Reference in a new issue