mirror of
https://github.com/jpawlowski/hass.tibber_prices.git
synced 2026-05-28 18:43:40 +00:00
fix(periods): keep negative best-price windows strictly local
Always treat prices at or below zero as valid best-price intervals, rescue short negative cores with directly adjacent cheap shoulders before min-length filtering, and block geometric or shape-based widening for periods that already contain a negative-price core. Impact: Negative best-price periods no longer expand into positive edge intervals on days with extreme negative prices.
This commit is contained in:
parent
c8f40e0b8a
commit
10c83d6720
6 changed files with 508 additions and 15 deletions
|
|
@ -16,6 +16,7 @@ from .period_building import (
|
||||||
add_interval_ends,
|
add_interval_ends,
|
||||||
build_periods,
|
build_periods,
|
||||||
calculate_reference_prices,
|
calculate_reference_prices,
|
||||||
|
extend_negative_core_periods_for_min_length,
|
||||||
extend_periods_across_midnight,
|
extend_periods_across_midnight,
|
||||||
filter_periods_by_end_date,
|
filter_periods_by_end_date,
|
||||||
filter_periods_by_min_length,
|
filter_periods_by_min_length,
|
||||||
|
|
@ -201,6 +202,17 @@ def calculate_periods(
|
||||||
len(raw_periods),
|
len(raw_periods),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Step 3.75: Rescue short negative best-price cores before min-length filtering.
|
||||||
|
# This keeps <= 0 prices as the hard core and only adds directly adjacent cheap
|
||||||
|
# shoulders when needed to reach the configured minimum length.
|
||||||
|
if not reverse_sort:
|
||||||
|
raw_periods = extend_negative_core_periods_for_min_length(
|
||||||
|
raw_periods,
|
||||||
|
all_prices_sorted,
|
||||||
|
min_period_length,
|
||||||
|
time=time,
|
||||||
|
)
|
||||||
|
|
||||||
# Step 4: Filter by minimum length
|
# Step 4: Filter by minimum length
|
||||||
raw_periods = filter_periods_by_min_length(raw_periods, min_period_length, time=time)
|
raw_periods = filter_periods_by_min_length(raw_periods, min_period_length, time=time)
|
||||||
_LOGGER.debug(
|
_LOGGER.debug(
|
||||||
|
|
|
||||||
|
|
@ -132,6 +132,17 @@ def check_interval_criteria(
|
||||||
Tuple of (in_flex, meets_min_distance)
|
Tuple of (in_flex, meets_min_distance)
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
# ============================================================
|
||||||
|
# FAST PATH: Negative/zero prices always qualify as best price
|
||||||
|
# ============================================================
|
||||||
|
# When price ≤ 0 the consumer is paid or gets free electricity.
|
||||||
|
# This is unconditionally the cheapest possible outcome regardless
|
||||||
|
# of daily average, flex setting, or level filter.
|
||||||
|
# Bypasses both flex AND min_distance: a negative price is always
|
||||||
|
# maximally "far below average" in the economically meaningful sense.
|
||||||
|
if not criteria.reverse_sort and price <= 0:
|
||||||
|
return True, True
|
||||||
|
|
||||||
# Normalize inputs to absolute values for consistent calculation
|
# Normalize inputs to absolute values for consistent calculation
|
||||||
flex_abs = abs(criteria.flex)
|
flex_abs = abs(criteria.flex)
|
||||||
min_distance_abs = abs(criteria.min_distance_from_avg)
|
min_distance_abs = abs(criteria.min_distance_from_avg)
|
||||||
|
|
@ -143,22 +154,19 @@ def check_interval_criteria(
|
||||||
# - Peak price (reverse_sort=True): daily MAXIMUM
|
# - Peak price (reverse_sort=True): daily MAXIMUM
|
||||||
# - Best price (reverse_sort=False): daily MINIMUM
|
# - Best price (reverse_sort=False): daily MINIMUM
|
||||||
#
|
#
|
||||||
|
# Standard formula (positive daily minimum):
|
||||||
# Flex base = max(price_span, abs(ref_price)):
|
# Flex base = max(price_span, abs(ref_price)):
|
||||||
# - On V-shape days (tiny minimum, large span): span wins → meaningful flex band
|
# - On V-shape days (tiny minimum, large span): span wins → meaningful flex band
|
||||||
# - On flat days (large minimum, small span): ref_price wins → same as before
|
# - On flat days (large minimum, small span): ref_price wins → same as before
|
||||||
#
|
#
|
||||||
# WHY NOT plain ref_price * flex: When daily_min is a single low outlier
|
# Examples with flex=15% (positive minimum):
|
||||||
# (e.g., min=1 ct, avg=19 ct), the flex band collapses to near-zero
|
# - V-shape: min=1 ct, avg=19 ct → span=18 ct → flex_base=18 → threshold=1+2.7=3.7 ct
|
||||||
# (1 ct * 15% = 0.15 ct) and no period of sufficient length can be found.
|
# - Flat: min=30 ct, avg=33 ct → span=3 ct → flex_base=30 → threshold=30+4.5=34.5 ct
|
||||||
#
|
# - Normal: min=10 ct, avg=20 ct → span=10 ct → flex_base=10 → threshold=10+1.5=11.5 ct
|
||||||
# WHY NOT plain span * flex: On flat days (e.g., min=30 ct, span=3 ct),
|
|
||||||
# this makes the band much narrower than before, breaking existing behaviour.
|
|
||||||
#
|
|
||||||
# Examples with flex=15%:
|
|
||||||
# - V-shape: min=1 ct, avg=19 ct → span=18 ct → flex_base=18 → threshold=1+2.7=3.7 ct (spans fixed)
|
|
||||||
# - Flat: min=30 ct, avg=33 ct → span=3 ct → flex_base=30 → threshold=30+4.5=34.5 ct (unchanged)
|
|
||||||
# - Normal: min=10 ct, avg=20 ct → span=10 ct → flex_base=10 → threshold=10+1.5=11.5 ct (unchanged)
|
|
||||||
|
|
||||||
|
# Positive shoulders around a short negative core are handled later in the
|
||||||
|
# raw-period pipeline, where adjacency can be evaluated locally. Keeping the
|
||||||
|
# interval filter day-agnostic avoids creating a global halo across the whole day.
|
||||||
price_span = abs(criteria.avg_price - criteria.ref_price)
|
price_span = abs(criteria.avg_price - criteria.ref_price)
|
||||||
flex_base = max(price_span, abs(criteria.ref_price))
|
flex_base = max(price_span, abs(criteria.ref_price))
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ from datetime import date, datetime, timedelta
|
||||||
import logging
|
import logging
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
from custom_components.tibber_prices.const import PRICE_LEVEL_MAPPING
|
from custom_components.tibber_prices.const import PRICE_LEVEL_CHEAP, PRICE_LEVEL_MAPPING, PRICE_LEVEL_VERY_CHEAP
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from custom_components.tibber_prices.coordinator.time_service import TibberPricesTimeService
|
from custom_components.tibber_prices.coordinator.time_service import TibberPricesTimeService
|
||||||
|
|
@ -19,6 +19,7 @@ _LOGGER_DETAILS = logging.getLogger(__name__ + ".details")
|
||||||
|
|
||||||
# Module-local log indentation (each module starts at level 0)
|
# Module-local log indentation (each module starts at level 0)
|
||||||
INDENT_L0 = "" # Entry point / main function
|
INDENT_L0 = "" # Entry point / main function
|
||||||
|
NEGATIVE_CORE_NO_SHOULDER_INTERVALS = 8 # 2 hours at 15-min resolution
|
||||||
|
|
||||||
|
|
||||||
def split_intervals_by_day(
|
def split_intervals_by_day(
|
||||||
|
|
@ -66,6 +67,182 @@ def _trim_trailing_gaps(period: list[dict]) -> list[dict]:
|
||||||
return period
|
return period
|
||||||
|
|
||||||
|
|
||||||
|
def _build_period_interval(price_data: dict, *, time: TibberPricesTimeService) -> dict | None:
|
||||||
|
"""Build the internal interval representation used by raw periods."""
|
||||||
|
starts_at = time.get_interval_time(price_data)
|
||||||
|
if starts_at is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
price_original = float(price_data.get("_original_price", price_data["total"]))
|
||||||
|
return {
|
||||||
|
"interval_hour": starts_at.hour,
|
||||||
|
"interval_minute": starts_at.minute,
|
||||||
|
"interval_time": f"{starts_at.hour:02d}:{starts_at.minute:02d}",
|
||||||
|
"price": price_original,
|
||||||
|
"interval_start": starts_at,
|
||||||
|
"smoothing_was_impactful": False,
|
||||||
|
"is_level_gap": False,
|
||||||
|
"geometric_bonus_applied": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _get_longest_negative_core_length(period: list[dict]) -> int:
|
||||||
|
"""Return the longest contiguous run of intervals with price <= 0."""
|
||||||
|
longest = 0
|
||||||
|
current = 0
|
||||||
|
|
||||||
|
for interval in period:
|
||||||
|
if float(interval.get("price", 0.0)) <= 0:
|
||||||
|
current += 1
|
||||||
|
longest = max(longest, current)
|
||||||
|
else:
|
||||||
|
current = 0
|
||||||
|
|
||||||
|
return longest
|
||||||
|
|
||||||
|
|
||||||
|
def _collect_contiguous_best_price_side(
|
||||||
|
interval_index: dict[datetime, dict],
|
||||||
|
start_cursor: datetime,
|
||||||
|
step: timedelta,
|
||||||
|
*,
|
||||||
|
max_intervals: int,
|
||||||
|
time: TibberPricesTimeService,
|
||||||
|
) -> list[dict]:
|
||||||
|
"""Collect directly adjacent favourable intervals on one side of a negative core."""
|
||||||
|
for target_level in (PRICE_LEVEL_VERY_CHEAP, PRICE_LEVEL_CHEAP):
|
||||||
|
additions: list[dict] = []
|
||||||
|
cursor = start_cursor
|
||||||
|
|
||||||
|
for _ in range(max_intervals):
|
||||||
|
price_data = interval_index.get(cursor)
|
||||||
|
if price_data is None or price_data.get("level") != target_level:
|
||||||
|
break
|
||||||
|
|
||||||
|
period_interval = _build_period_interval(price_data, time=time)
|
||||||
|
if period_interval is None:
|
||||||
|
break
|
||||||
|
|
||||||
|
additions.append(period_interval)
|
||||||
|
cursor += step
|
||||||
|
|
||||||
|
if additions:
|
||||||
|
if step < timedelta(0):
|
||||||
|
additions.reverse()
|
||||||
|
return additions
|
||||||
|
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def _select_nearest_extensions(
|
||||||
|
left_candidates: list[dict],
|
||||||
|
right_candidates: list[dict],
|
||||||
|
*,
|
||||||
|
max_total_additions: int,
|
||||||
|
) -> tuple[list[dict], list[dict]]:
|
||||||
|
"""Select the nearest left/right additions until the target length is reached."""
|
||||||
|
left_nearest = list(reversed(left_candidates))
|
||||||
|
right_nearest = right_candidates.copy()
|
||||||
|
selected_left_nearest: list[dict] = []
|
||||||
|
selected_right: list[dict] = []
|
||||||
|
prefer_left = bool(left_nearest)
|
||||||
|
|
||||||
|
while max_total_additions > 0 and (left_nearest or right_nearest):
|
||||||
|
if prefer_left and left_nearest:
|
||||||
|
selected_left_nearest.append(left_nearest.pop(0))
|
||||||
|
max_total_additions -= 1
|
||||||
|
elif not prefer_left and right_nearest:
|
||||||
|
selected_right.append(right_nearest.pop(0))
|
||||||
|
max_total_additions -= 1
|
||||||
|
elif left_nearest:
|
||||||
|
selected_left_nearest.append(left_nearest.pop(0))
|
||||||
|
max_total_additions -= 1
|
||||||
|
elif right_nearest:
|
||||||
|
selected_right.append(right_nearest.pop(0))
|
||||||
|
max_total_additions -= 1
|
||||||
|
|
||||||
|
prefer_left = not prefer_left
|
||||||
|
|
||||||
|
return list(reversed(selected_left_nearest)), selected_right
|
||||||
|
|
||||||
|
|
||||||
|
def extend_negative_core_periods_for_min_length(
|
||||||
|
periods: list[list[dict]],
|
||||||
|
all_prices: list[dict],
|
||||||
|
min_period_length: int,
|
||||||
|
*,
|
||||||
|
time: TibberPricesTimeService,
|
||||||
|
) -> list[list[dict]]:
|
||||||
|
"""Locally extend short negative best-price cores into directly adjacent cheap shoulders.
|
||||||
|
|
||||||
|
This rescue step is intentionally narrow:
|
||||||
|
- only periods that already contain a negative/zero core are considered
|
||||||
|
- only periods shorter than the configured minimum length are extended
|
||||||
|
- only directly adjacent VERY_CHEAP/CHEAP intervals may be added
|
||||||
|
- multi-hour negative blocks stay untouched to preserve a strict negative-only period
|
||||||
|
"""
|
||||||
|
if not periods:
|
||||||
|
return periods
|
||||||
|
|
||||||
|
min_intervals = time.minutes_to_intervals(min_period_length)
|
||||||
|
if min_intervals <= 0:
|
||||||
|
return periods
|
||||||
|
|
||||||
|
interval_index: dict[datetime, dict] = {}
|
||||||
|
for price_data in all_prices:
|
||||||
|
starts_at = time.get_interval_time(price_data)
|
||||||
|
if starts_at is not None:
|
||||||
|
interval_index[starts_at] = price_data
|
||||||
|
|
||||||
|
interval_duration = time.get_interval_duration()
|
||||||
|
extended_periods: list[list[dict]] = []
|
||||||
|
|
||||||
|
for period in periods:
|
||||||
|
negative_core_length = _get_longest_negative_core_length(period)
|
||||||
|
if (
|
||||||
|
negative_core_length == 0
|
||||||
|
or negative_core_length >= NEGATIVE_CORE_NO_SHOULDER_INTERVALS
|
||||||
|
or len(period) >= min_intervals
|
||||||
|
):
|
||||||
|
extended_periods.append(period)
|
||||||
|
continue
|
||||||
|
|
||||||
|
period_start = period[0].get("interval_start")
|
||||||
|
period_end = period[-1].get("interval_start")
|
||||||
|
if period_start is None or period_end is None:
|
||||||
|
extended_periods.append(period)
|
||||||
|
continue
|
||||||
|
|
||||||
|
needed_intervals = min_intervals - len(period)
|
||||||
|
left_candidates = _collect_contiguous_best_price_side(
|
||||||
|
interval_index,
|
||||||
|
period_start - interval_duration,
|
||||||
|
-interval_duration,
|
||||||
|
max_intervals=needed_intervals,
|
||||||
|
time=time,
|
||||||
|
)
|
||||||
|
right_candidates = _collect_contiguous_best_price_side(
|
||||||
|
interval_index,
|
||||||
|
period_end + interval_duration,
|
||||||
|
interval_duration,
|
||||||
|
max_intervals=needed_intervals,
|
||||||
|
time=time,
|
||||||
|
)
|
||||||
|
|
||||||
|
selected_left, selected_right = _select_nearest_extensions(
|
||||||
|
left_candidates,
|
||||||
|
right_candidates,
|
||||||
|
max_total_additions=needed_intervals,
|
||||||
|
)
|
||||||
|
|
||||||
|
if selected_left or selected_right:
|
||||||
|
extended_periods.append([*selected_left, *period, *selected_right])
|
||||||
|
else:
|
||||||
|
extended_periods.append(period)
|
||||||
|
|
||||||
|
return extended_periods
|
||||||
|
|
||||||
|
|
||||||
def build_periods(
|
def build_periods(
|
||||||
all_prices: list[dict],
|
all_prices: list[dict],
|
||||||
price_context: dict[str, Any],
|
price_context: dict[str, Any],
|
||||||
|
|
@ -144,7 +321,6 @@ def build_periods(
|
||||||
)
|
)
|
||||||
for day in ref_prices
|
for day in ref_prices
|
||||||
}
|
}
|
||||||
|
|
||||||
for price_data in all_prices:
|
for price_data in all_prices:
|
||||||
starts_at = time.get_interval_time(price_data)
|
starts_at = time.get_interval_time(price_data)
|
||||||
if starts_at is None:
|
if starts_at is None:
|
||||||
|
|
@ -173,9 +349,16 @@ def build_periods(
|
||||||
# Check flex and minimum distance criteria (using smoothed price and interval's own day reference)
|
# Check flex and minimum distance criteria (using smoothed price and interval's own day reference)
|
||||||
criteria = criteria_by_day[ref_date]
|
criteria = criteria_by_day[ref_date]
|
||||||
|
|
||||||
# Compute geometric flex bonus if pattern-aware expansion is enabled
|
# Compute geometric flex bonus if pattern-aware expansion is enabled.
|
||||||
|
# Best-price days with a negative daily minimum are handled by the dedicated
|
||||||
|
# negative-core logic; applying a day-wide geometric valley bonus there would
|
||||||
|
# reintroduce broad positive shoulders around a negative core.
|
||||||
geo_bonus = 0.0
|
geo_bonus = 0.0
|
||||||
if geometric_extra_flex > 0 and day_patterns_by_date is not None:
|
if (
|
||||||
|
geometric_extra_flex > 0
|
||||||
|
and day_patterns_by_date is not None
|
||||||
|
and not (not reverse_sort and criteria.ref_price < 0)
|
||||||
|
):
|
||||||
day_pattern_for_date = day_patterns_by_date.get(ref_date)
|
day_pattern_for_date = day_patterns_by_date.get(ref_date)
|
||||||
geo_bonus = compute_geometric_flex_bonus(
|
geo_bonus = compute_geometric_flex_bonus(
|
||||||
starts_at,
|
starts_at,
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,7 @@ if TYPE_CHECKING:
|
||||||
from .types import TibberPricesThresholdConfig
|
from .types import TibberPricesThresholdConfig
|
||||||
|
|
||||||
_INTERVAL_DURATION = timedelta(minutes=15)
|
_INTERVAL_DURATION = timedelta(minutes=15)
|
||||||
|
NEGATIVE_CORE_DISABLE_EXTENSION_INTERVALS = 1
|
||||||
|
|
||||||
|
|
||||||
def extend_periods_for_shape(
|
def extend_periods_for_shape(
|
||||||
|
|
@ -269,6 +270,12 @@ def _extend_period_edges(
|
||||||
# Collect original intervals early – needed for the majority gate below.
|
# Collect original intervals early – needed for the majority gate below.
|
||||||
original_intervals = _collect_original_intervals(start, end, interval_index)
|
original_intervals = _collect_original_intervals(start, end, interval_index)
|
||||||
|
|
||||||
|
# Negative-price best-price periods use dedicated core/shoulder handling earlier
|
||||||
|
# in the pipeline. Do not widen them again here just because adjacent intervals
|
||||||
|
# are labelled VERY_CHEAP/CHEAP.
|
||||||
|
if not reverse_sort and _contains_negative_core(original_intervals):
|
||||||
|
return period
|
||||||
|
|
||||||
# ── walk LEFT (earlier than period start) ─────────────────────────────────
|
# ── walk LEFT (earlier than period start) ─────────────────────────────────
|
||||||
left_cursor = start - _INTERVAL_DURATION
|
left_cursor = start - _INTERVAL_DURATION
|
||||||
left_additions = _walk_contiguous(interval_index, left_cursor, backward_step, primary_level, max_intervals)
|
left_additions = _walk_contiguous(interval_index, left_cursor, backward_step, primary_level, max_intervals)
|
||||||
|
|
@ -401,3 +408,18 @@ def _collect_original_intervals(
|
||||||
result.append(iv)
|
result.append(iv)
|
||||||
cursor += _INTERVAL_DURATION
|
cursor += _INTERVAL_DURATION
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _contains_negative_core(intervals: list[dict[str, Any]]) -> bool:
|
||||||
|
"""Return True when the period contains at least one negative/zero-price interval."""
|
||||||
|
negative_run = 0
|
||||||
|
|
||||||
|
for interval in intervals:
|
||||||
|
if float(interval.get("total", 0.0)) <= 0:
|
||||||
|
negative_run += 1
|
||||||
|
if negative_run >= NEGATIVE_CORE_DISABLE_EXTENSION_INTERVALS:
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
negative_run = 0
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
|
||||||
|
|
@ -679,3 +679,100 @@ class TestRealWorldScenarios:
|
||||||
|
|
||||||
assert in_flex_min is True, "Negative minimum should pass"
|
assert in_flex_min is True, "Negative minimum should pass"
|
||||||
assert in_flex_within is True, "Within flex of negative min should pass"
|
assert in_flex_within is True, "Within flex of negative min should pass"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestNegativePriceFastPath:
|
||||||
|
"""
|
||||||
|
Tests for the negative/zero price fast path.
|
||||||
|
|
||||||
|
Regression Tests:
|
||||||
|
- Negative prices not fully included in best price (Apr 2026): Even with
|
||||||
|
VERY_CHEAP extension and max flex (50%), the old formula excluded large
|
||||||
|
parts of a negative-price day because the flex band was anchored at the
|
||||||
|
negative daily minimum. Example: min=-38 ct, flex=50% → threshold=-19 ct,
|
||||||
|
excluding -18 ct, -10 ct, 0 ct, etc.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def test_negative_price_always_qualifies_best_price(self) -> None:
|
||||||
|
"""Any negative price unconditionally qualifies as best price (fast path)."""
|
||||||
|
# Simulate tomorrow: min=-38 ct, avg=-3 ct
|
||||||
|
criteria = TibberPricesIntervalCriteria(
|
||||||
|
ref_price=-38.0,
|
||||||
|
avg_price=-3.0,
|
||||||
|
flex=0.15,
|
||||||
|
min_distance_from_avg=5.0,
|
||||||
|
reverse_sort=False,
|
||||||
|
)
|
||||||
|
for price in [-38.0, -20.0, -5.0, -0.01]:
|
||||||
|
in_flex, meets_distance = check_interval_criteria(price, criteria)
|
||||||
|
assert in_flex is True, f"Negative price {price} ct should always pass flex"
|
||||||
|
assert meets_distance is True, f"Negative price {price} ct should always pass distance"
|
||||||
|
|
||||||
|
def test_zero_price_always_qualifies_best_price(self) -> None:
|
||||||
|
"""Zero price unconditionally qualifies as best price (fast path)."""
|
||||||
|
criteria = TibberPricesIntervalCriteria(
|
||||||
|
ref_price=-10.0,
|
||||||
|
avg_price=5.0,
|
||||||
|
flex=0.15,
|
||||||
|
min_distance_from_avg=5.0,
|
||||||
|
reverse_sort=False,
|
||||||
|
)
|
||||||
|
in_flex, meets_distance = check_interval_criteria(0.0, criteria)
|
||||||
|
assert in_flex is True, "Zero price should always pass flex"
|
||||||
|
assert meets_distance is True, "Zero price should always pass distance"
|
||||||
|
|
||||||
|
def test_negative_price_does_not_qualify_peak_price(self) -> None:
|
||||||
|
"""Negative prices should NOT qualify as peak prices."""
|
||||||
|
# Even with high average, a negative price is never a "peak"
|
||||||
|
criteria = TibberPricesIntervalCriteria(
|
||||||
|
ref_price=30.0,
|
||||||
|
avg_price=20.0,
|
||||||
|
flex=0.50,
|
||||||
|
min_distance_from_avg=5.0,
|
||||||
|
reverse_sort=True,
|
||||||
|
)
|
||||||
|
in_flex, _meets_distance = check_interval_criteria(-5.0, criteria)
|
||||||
|
assert in_flex is False, "Negative price should fail peak price flex check"
|
||||||
|
|
||||||
|
def test_positive_price_is_not_auto_qualified_on_negative_day(self) -> None:
|
||||||
|
"""Positive prices must still pass the normal interval criteria."""
|
||||||
|
criteria = TibberPricesIntervalCriteria(
|
||||||
|
ref_price=-38.0,
|
||||||
|
avg_price=5.0,
|
||||||
|
flex=0.15,
|
||||||
|
min_distance_from_avg=5.0,
|
||||||
|
reverse_sort=False,
|
||||||
|
)
|
||||||
|
in_flex, meets_distance = check_interval_criteria(2.0, criteria)
|
||||||
|
assert in_flex is False, "Positive prices should not get a day-global negative halo"
|
||||||
|
assert meets_distance is True, "Distance may still pass even when flex fails"
|
||||||
|
|
||||||
|
def test_regression_old_formula_would_exclude_most_intervals(self) -> None:
|
||||||
|
"""
|
||||||
|
Regression: the old flex formula (anchored at negative min) excluded
|
||||||
|
almost all intervals on a day with extreme negative prices.
|
||||||
|
|
||||||
|
Old formula (BROKEN):
|
||||||
|
flex_base = max(|avg - min|, |min|) = max(35, 38) = 38
|
||||||
|
threshold = -38 + 38 * 0.15 = -32.3 ct
|
||||||
|
→ only prices ≤ -32.3 ct qualify!
|
||||||
|
|
||||||
|
New formula:
|
||||||
|
Fast path: price ≤ 0 → always (True, True)
|
||||||
|
Zero-anchored: threshold = abs(-3) * 0.15 = 0.45 ct above zero
|
||||||
|
→ ALL negative prices qualify, plus tiny halo near 0
|
||||||
|
"""
|
||||||
|
criteria = TibberPricesIntervalCriteria(
|
||||||
|
ref_price=-38.0,
|
||||||
|
avg_price=-3.0,
|
||||||
|
flex=0.15,
|
||||||
|
min_distance_from_avg=5.0,
|
||||||
|
reverse_sort=False,
|
||||||
|
)
|
||||||
|
# All these prices should pass with new logic but would have FAILED with old logic
|
||||||
|
prices_that_now_pass = [-32.0, -20.0, -10.0, -5.0, -1.0, -0.01, 0.0]
|
||||||
|
for price in prices_that_now_pass:
|
||||||
|
in_flex, meets_distance = check_interval_criteria(price, criteria)
|
||||||
|
assert in_flex is True, f"Price {price} ct should pass with new logic (regression)"
|
||||||
|
assert meets_distance is True, f"Price {price} ct should pass distance (regression)"
|
||||||
|
|
|
||||||
171
tests/test_negative_best_price_periods.py
Normal file
171
tests/test_negative_best_price_periods.py
Normal file
|
|
@ -0,0 +1,171 @@
|
||||||
|
"""Regression tests for best-price periods with negative prices."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from custom_components.tibber_prices.coordinator.period_handlers.core import calculate_periods
|
||||||
|
from custom_components.tibber_prices.coordinator.period_handlers.types import TibberPricesPeriodConfig
|
||||||
|
from custom_components.tibber_prices.coordinator.time_service import TibberPricesTimeService
|
||||||
|
|
||||||
|
|
||||||
|
def _create_interval(dt: datetime, price: float, level: str) -> dict:
|
||||||
|
"""Create a single interval dict."""
|
||||||
|
rating = "LOW" if price <= 5.0 else "NORMAL"
|
||||||
|
return {
|
||||||
|
"startsAt": dt,
|
||||||
|
"total": price,
|
||||||
|
"level": level,
|
||||||
|
"rating_level": rating,
|
||||||
|
"_original_price": price,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _build_day_with_overrides(overrides: dict[tuple[int, int], tuple[float, str]]) -> list[dict]:
|
||||||
|
"""Build a day of 15-minute intervals with targeted overrides."""
|
||||||
|
tz = ZoneInfo("Europe/Berlin")
|
||||||
|
base = datetime(2025, 4, 25, 0, 0, 0, tzinfo=tz)
|
||||||
|
intervals: list[dict] = []
|
||||||
|
|
||||||
|
for hour in range(24):
|
||||||
|
for minute in (0, 15, 30, 45):
|
||||||
|
price, level = overrides.get((hour, minute), (20.0, "NORMAL"))
|
||||||
|
intervals.append(_create_interval(base.replace(hour=hour, minute=minute), price, level))
|
||||||
|
|
||||||
|
return intervals
|
||||||
|
|
||||||
|
|
||||||
|
def _create_time_service() -> TibberPricesTimeService:
|
||||||
|
"""Create a deterministic time service for period calculation."""
|
||||||
|
tz = ZoneInfo("Europe/Berlin")
|
||||||
|
return TibberPricesTimeService(datetime(2025, 4, 25, 12, 0, 0, tzinfo=tz))
|
||||||
|
|
||||||
|
|
||||||
|
def _create_day_pattern(valley_start: tuple[int, int], valley_end: tuple[int, int]) -> dict:
|
||||||
|
"""Create a minimal day-pattern dict for geometric flex tests."""
|
||||||
|
tz = ZoneInfo("Europe/Berlin")
|
||||||
|
base = datetime(2025, 4, 25, 0, 0, 0, tzinfo=tz)
|
||||||
|
return {
|
||||||
|
"pattern": "valley",
|
||||||
|
"confidence": 1.0,
|
||||||
|
"day_cv_percent": 100.0,
|
||||||
|
"segments": [],
|
||||||
|
"extreme_time": base.replace(hour=13, minute=30),
|
||||||
|
"valley_start": base.replace(hour=valley_start[0], minute=valley_start[1]),
|
||||||
|
"valley_end": base.replace(hour=valley_end[0], minute=valley_end[1]),
|
||||||
|
"peak_start": None,
|
||||||
|
"peak_end": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestNegativeBestPricePeriods:
|
||||||
|
"""Validate local shoulder rescue around short negative cores only."""
|
||||||
|
|
||||||
|
def test_short_negative_dip_can_be_rescued_by_local_shoulders(self) -> None:
|
||||||
|
"""A short negative core may extend into directly adjacent cheap shoulders."""
|
||||||
|
intervals = _build_day_with_overrides(
|
||||||
|
{
|
||||||
|
(10, 45): (5.0, "CHEAP"),
|
||||||
|
(11, 0): (2.0, "CHEAP"),
|
||||||
|
(11, 15): (-1.5, "VERY_CHEAP"),
|
||||||
|
(11, 30): (-1.0, "VERY_CHEAP"),
|
||||||
|
(11, 45): (2.0, "CHEAP"),
|
||||||
|
(12, 0): (5.0, "CHEAP"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
config = TibberPricesPeriodConfig(
|
||||||
|
reverse_sort=False,
|
||||||
|
flex=0.15,
|
||||||
|
min_distance_from_avg=5.0,
|
||||||
|
min_period_length=60,
|
||||||
|
)
|
||||||
|
|
||||||
|
result = calculate_periods(intervals, config=config, time=_create_time_service())
|
||||||
|
periods = result["periods"]
|
||||||
|
|
||||||
|
assert len(periods) == 1, "Expected the short negative dip to survive as one local period"
|
||||||
|
assert periods[0]["start"].hour == 11 and periods[0]["start"].minute == 0
|
||||||
|
assert periods[0]["end"].hour == 12 and periods[0]["end"].minute == 0
|
||||||
|
assert periods[0]["duration_minutes"] == 60
|
||||||
|
|
||||||
|
def test_long_negative_block_stays_negative_only(self) -> None:
|
||||||
|
"""A multi-hour negative block must not pull in positive shoulders."""
|
||||||
|
intervals = _build_day_with_overrides(
|
||||||
|
{
|
||||||
|
(10, 30): (2.0, "CHEAP"),
|
||||||
|
(10, 45): (2.0, "CHEAP"),
|
||||||
|
(11, 0): (-1.5, "VERY_CHEAP"),
|
||||||
|
(11, 15): (-1.4, "VERY_CHEAP"),
|
||||||
|
(11, 30): (-1.3, "VERY_CHEAP"),
|
||||||
|
(11, 45): (-1.2, "VERY_CHEAP"),
|
||||||
|
(12, 0): (-1.1, "VERY_CHEAP"),
|
||||||
|
(12, 15): (-1.0, "VERY_CHEAP"),
|
||||||
|
(12, 30): (-0.9, "VERY_CHEAP"),
|
||||||
|
(12, 45): (-0.8, "VERY_CHEAP"),
|
||||||
|
(13, 0): (2.0, "CHEAP"),
|
||||||
|
(13, 15): (2.0, "CHEAP"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
config = TibberPricesPeriodConfig(
|
||||||
|
reverse_sort=False,
|
||||||
|
flex=0.15,
|
||||||
|
min_distance_from_avg=5.0,
|
||||||
|
min_period_length=180,
|
||||||
|
)
|
||||||
|
|
||||||
|
result = calculate_periods(intervals, config=config, time=_create_time_service())
|
||||||
|
|
||||||
|
assert result["periods"] == [], "Long negative blocks should not be widened with positive shoulders"
|
||||||
|
|
||||||
|
def test_negative_core_ignores_geometric_and_shape_extension(self) -> None:
|
||||||
|
"""Negative best-price periods must not widen via geometric or shape extension."""
|
||||||
|
intervals = _build_day_with_overrides(
|
||||||
|
{
|
||||||
|
(11, 45): (7.93, "VERY_CHEAP"),
|
||||||
|
(12, 0): (4.5, "VERY_CHEAP"),
|
||||||
|
(12, 15): (-1.0, "VERY_CHEAP"),
|
||||||
|
(12, 30): (-2.0, "VERY_CHEAP"),
|
||||||
|
(12, 45): (-3.0, "VERY_CHEAP"),
|
||||||
|
(13, 0): (-4.0, "VERY_CHEAP"),
|
||||||
|
(13, 15): (-5.36, "VERY_CHEAP"),
|
||||||
|
(13, 30): (-4.5, "VERY_CHEAP"),
|
||||||
|
(13, 45): (-3.5, "VERY_CHEAP"),
|
||||||
|
(14, 0): (-2.5, "VERY_CHEAP"),
|
||||||
|
(14, 15): (-1.5, "VERY_CHEAP"),
|
||||||
|
(14, 30): (-0.5, "VERY_CHEAP"),
|
||||||
|
(14, 45): (2.0, "VERY_CHEAP"),
|
||||||
|
(15, 0): (4.0, "VERY_CHEAP"),
|
||||||
|
(15, 15): (7.0, "VERY_CHEAP"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
config = TibberPricesPeriodConfig(
|
||||||
|
reverse_sort=False,
|
||||||
|
flex=0.15,
|
||||||
|
min_distance_from_avg=5.0,
|
||||||
|
min_period_length=60,
|
||||||
|
extend_to_extreme=True,
|
||||||
|
max_extension_intervals=4,
|
||||||
|
geometric_extra_flex=0.20,
|
||||||
|
)
|
||||||
|
day_patterns_by_date = {
|
||||||
|
datetime(2025, 4, 25, 0, 0, 0, tzinfo=ZoneInfo("Europe/Berlin")).date(): _create_day_pattern(
|
||||||
|
(11, 45), (15, 15)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
result = calculate_periods(
|
||||||
|
intervals,
|
||||||
|
config=config,
|
||||||
|
time=_create_time_service(),
|
||||||
|
day_patterns_by_date=day_patterns_by_date,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(result["periods"]) == 1
|
||||||
|
assert result["periods"][0]["start"].hour == 12 and result["periods"][0]["start"].minute == 15
|
||||||
|
assert result["periods"][0]["end"].hour == 14 and result["periods"][0]["end"].minute == 45
|
||||||
|
assert result["periods"][0].get("geometric_extension_active") is None
|
||||||
|
assert result["periods"][0].get("extension_intervals_added") is None
|
||||||
Loading…
Reference in a new issue