fix(periods): restore relaxation metadata marking with correct sign handling

Restored mark_periods_with_relaxation() function and added call in
relax_all_prices() to properly mark periods found through relaxation.

Problem: Periods found via relaxation were missing metadata attributes:
- relaxation_active
- relaxation_level
- relaxation_threshold_original_%
- relaxation_threshold_applied_%

These attributes are expected by:
- period_overlap.py: For merging periods with correct relaxation info
- binary_sensor/attributes.py: For displaying relaxation info to users

Implementation:
- Added reverse_sort parameter to preserve sign semantics
- For Best Price: Store positive thresholds (e.g., +15%, +18%)
- For Peak Price: Store negative thresholds (e.g., -20%, -23%)
- Mark periods immediately after calculate_periods() and before
  resolve_period_overlaps() so metadata is preserved during merging

Impact: Users can now see which periods were found through relaxation
and at what flex threshold. Peak Price periods show negative thresholds
matching the user's configuration semantics (negative = below maximum).
This commit is contained in:
Julian Pawlowski 2025-11-21 17:40:15 +00:00
parent 14b68a504b
commit f6b553d90e

View file

@ -60,6 +60,41 @@ def group_periods_by_day(periods: list[dict]) -> dict[date, list[dict]]:
return periods_by_day return periods_by_day
def mark_periods_with_relaxation(
periods: list[dict],
relaxation_level: str,
original_threshold: float,
applied_threshold: float,
*,
reverse_sort: bool = False,
) -> None:
"""
Mark periods with relaxation information (mutates period dicts in-place).
Uses consistent 'relaxation_*' prefix for all relaxation-related attributes.
These attributes are read by period_overlap.py and binary_sensor/attributes.py.
For Peak Price periods (reverse_sort=True), thresholds are stored as negative
values to match the user's configuration semantics (negative flex = below maximum).
Args:
periods: List of period dicts to mark
relaxation_level: String describing the relaxation level (e.g., "flex=18.0% +level_any")
original_threshold: Original flex threshold value (decimal, e.g., 0.15 for 15%)
applied_threshold: Actually applied threshold value (decimal, e.g., 0.18 for 18%)
reverse_sort: True for Peak Price (negative values), False for Best Price (positive values)
"""
for period in periods:
period["relaxation_active"] = True
period["relaxation_level"] = relaxation_level
# Convert decimal to percentage for display
# For Peak Prices: Store as negative to match user's config semantics
sign = -1 if reverse_sort else 1
period["relaxation_threshold_original_%"] = round(original_threshold * 100 * sign, 1)
period["relaxation_threshold_applied_%"] = round(applied_threshold * 100 * sign, 1)
def group_prices_by_day(all_prices: list[dict], *, time: TibberPricesTimeService) -> dict[date, list[dict]]: def group_prices_by_day(all_prices: list[dict], *, time: TibberPricesTimeService) -> dict[date, list[dict]]:
""" """
Group price intervals by the day they belong to (today and future only). Group price intervals by the day they belong to (today and future only).
@ -86,88 +121,6 @@ def group_prices_by_day(all_prices: list[dict], *, time: TibberPricesTimeService
return prices_by_day return prices_by_day
def check_min_periods_per_day(
periods: list[dict], min_periods: int, all_prices: list[dict], *, time: TibberPricesTimeService
) -> bool:
"""
Check if minimum periods requirement is met for each day individually.
Returns True if we should STOP relaxation (enough periods found per day).
Returns False if we should CONTINUE relaxation (not enough periods yet).
Args:
periods: List of period summary dicts
min_periods: Minimum number of periods required per day
all_prices: All available price intervals (used to determine which days have data)
time: TibberPricesTimeService instance (required)
Returns:
True if every day with price data has at least min_periods, False otherwise
"""
if not periods:
return False # No periods at all, continue relaxation
# Get all days that have price data (today and future only, not yesterday)
today = time.now().date()
available_days = set()
for price in all_prices:
starts_at = time.get_interval_time(price)
if starts_at:
price_date = starts_at.date()
# Only count today and future days (not yesterday)
if price_date >= today:
available_days.add(price_date)
if not available_days:
return False # No price data for today/future, continue relaxation
# Group found periods by day
periods_by_day = group_periods_by_day(periods)
# Check each day with price data: ALL must have at least min_periods
for day in available_days:
day_periods = periods_by_day.get(day, [])
period_count = len(day_periods)
if period_count < min_periods:
_LOGGER.debug(
"Day %s has only %d periods (need %d) - continuing relaxation",
day,
period_count,
min_periods,
)
return False # This day doesn't have enough, continue relaxation
# All days with price data have enough periods, stop relaxation
return True
def mark_periods_with_relaxation(
periods: list[dict],
relaxation_level: str,
original_threshold: float,
applied_threshold: float,
) -> None:
"""
Mark periods with relaxation information (mutates period dicts in-place).
Uses consistent 'relaxation_*' prefix for all relaxation-related attributes.
Args:
periods: List of period dicts to mark
relaxation_level: String describing the relaxation level
original_threshold: Original flex threshold value (decimal, e.g., 0.19 for 19%)
applied_threshold: Actually applied threshold value (decimal, e.g., 0.25 for 25%)
"""
for period in periods:
period["relaxation_active"] = True
period["relaxation_level"] = relaxation_level
# Convert decimal to percentage for display (0.19 → 19.0)
period["relaxation_threshold_original_%"] = round(original_threshold * 100, 1)
period["relaxation_threshold_applied_%"] = round(applied_threshold * 100, 1)
def calculate_periods_with_relaxation( # noqa: PLR0913, PLR0915 - Per-day relaxation requires many parameters and statements def calculate_periods_with_relaxation( # noqa: PLR0913, PLR0915 - Per-day relaxation requires many parameters and statements
all_prices: list[dict], all_prices: list[dict],
*, *,
@ -494,6 +447,15 @@ def relax_all_prices( # noqa: PLR0913 - Comprehensive filter relaxation require
len(new_periods), len(new_periods),
) )
# Mark newly found periods with relaxation metadata BEFORE merging
mark_periods_with_relaxation(
new_periods,
relaxation_level=phase_label_full,
original_threshold=base_flex,
applied_threshold=current_flex,
reverse_sort=config.reverse_sort,
)
# Resolve overlaps between existing and new periods # Resolve overlaps between existing and new periods
combined, standalone_count = resolve_period_overlaps( combined, standalone_count = resolve_period_overlaps(
existing_periods=existing_periods, existing_periods=existing_periods,