fix(periods): stabilize best and peak period outputs

Recompute merged relaxed periods from raw intervals, harden numeric period option normalization, update day-volatility handling for zero or negative averages, and expose day context on period binary sensors.

Add focused regressions for overlap merges, cache invalidation, day statistics, and visible binary sensor attributes.

Impact: Best and peak period entities stay consistent on negative-price days, refresh correctly when same-day prices change, and expose the documented day context attributes.
This commit is contained in:
Julian Pawlowski 2026-04-25 22:46:38 +00:00
parent 10c83d6720
commit bbcfdd4443
12 changed files with 1112 additions and 123 deletions

View file

@ -283,6 +283,22 @@ def add_price_attributes(attributes: dict, current_period: dict, factor: int) ->
attributes["volatility"] = current_period["volatility"] # Volatility is not a price, keep as-is attributes["volatility"] = current_period["volatility"] # Volatility is not a price, keep as-is
def add_day_statistics_attributes(attributes: dict, current_period: dict) -> None:
"""Add per-day context attributes for the current/next period.
Day price range fields are already stored in minor currency units (ct/ore)
by the period summary builder and therefore must not be converted again here.
"""
if "day_volatility_%" in current_period:
attributes["day_volatility_%"] = current_period["day_volatility_%"]
if "day_price_min" in current_period:
attributes["day_price_min"] = current_period["day_price_min"]
if "day_price_max" in current_period:
attributes["day_price_max"] = current_period["day_price_max"]
if "day_price_span" in current_period:
attributes["day_price_span"] = current_period["day_price_span"]
def add_comparison_attributes(attributes: dict, current_period: dict, factor: int) -> None: def add_comparison_attributes(attributes: dict, current_period: dict, factor: int) -> None:
""" """
Add price comparison attributes (priority 4). Add price comparison attributes (priority 4).
@ -473,12 +489,13 @@ def build_final_attributes_simple(
2. Core decision attributes (level, rating_level, rating_difference_%) 2. Core decision attributes (level, rating_level, rating_difference_%)
3. Price statistics (price_mean, price_median, price_min, price_max, price_spread, volatility) 3. Price statistics (price_mean, price_median, price_min, price_max, price_spread, volatility)
4. Price differences (period_price_diff_from_daily_min, period_price_diff_from_daily_min_%) 4. Price differences (period_price_diff_from_daily_min, period_price_diff_from_daily_min_%)
5. Detail information (period_interval_count, period_position, period_count_total, period_count_remaining) 5. Day context (day_volatility_%, day_price_min, day_price_max, day_price_span)
6. Relaxation information (relaxation_active, relaxation_level, relaxation_threshold_original_%, 6. Detail information (period_interval_count, period_position, period_count_total, period_count_remaining)
7. Relaxation information (relaxation_active, relaxation_level, relaxation_threshold_original_%,
relaxation_threshold_applied_%) - only if current period was relaxed relaxation_threshold_applied_%) - only if current period was relaxed
7. Calculation summary (min_periods_configured, flat_days_detected, 8. Calculation summary (min_periods_configured, flat_days_detected,
relaxation_incomplete) - diagnostic info about the overall calculation relaxation_incomplete) - diagnostic info about the overall calculation
8. Meta information (periods list) 9. Meta information (periods list)
Args: Args:
current_period: The current or next period (already complete from coordinator) current_period: The current or next period (already complete from coordinator)
@ -514,20 +531,23 @@ def build_final_attributes_simple(
# 4. Price differences (converted to display units) # 4. Price differences (converted to display units)
add_comparison_attributes(attributes, current_period, factor) add_comparison_attributes(attributes, current_period, factor)
# 5. Detail information # 5. Day context attributes (already in minor units)
add_day_statistics_attributes(attributes, current_period)
# 6. Detail information
add_detail_attributes(attributes, current_period) add_detail_attributes(attributes, current_period)
# 5.5 Per-day period counts (how many cheap/peak periods per day) # 6.5 Per-day period counts (how many cheap/peak periods per day)
add_period_count_attributes(attributes, period_summaries, time) add_period_count_attributes(attributes, period_summaries, time)
# 6. Relaxation information (only if current period was relaxed) # 7. Relaxation information (only if current period was relaxed)
add_relaxation_attributes(attributes, current_period) add_relaxation_attributes(attributes, current_period)
# 7. Calculation summary (diagnostic: min_periods_configured, flat_days_detected, etc.) # 8. Calculation summary (diagnostic: min_periods_configured, flat_days_detected, etc.)
if period_metadata: if period_metadata:
add_calculation_summary_attributes(attributes, period_metadata) add_calculation_summary_attributes(attributes, period_metadata)
# 8. Meta information (periods array - prices converted to display units) # 9. Meta information (periods array - prices converted to display units)
attributes["periods"] = _convert_periods_to_display_units(period_summaries, factor) attributes["periods"] = _convert_periods_to_display_units(period_summaries, factor)
return attributes return attributes

View file

@ -101,13 +101,19 @@ class PeriodSummary(TypedDict, total=False):
period_price_diff_from_daily_min: float # Difference from daily min period_price_diff_from_daily_min: float # Difference from daily min
period_price_diff_from_daily_min_pct: float # Difference from daily min (%) period_price_diff_from_daily_min_pct: float # Difference from daily min (%)
# Detail information (priority 5) # Day context (priority 5)
day_volatility_pct: float | None # Volatility of the period's day (%), None for zero-average days
day_price_min: float # Daily minimum price in minor currency (ct/ore)
day_price_max: float # Daily maximum price in minor currency (ct/ore)
day_price_span: float # Daily price span in minor currency (ct/ore)
# Detail information (priority 6)
period_interval_count: int # Number of intervals in period period_interval_count: int # Number of intervals in period
period_position: int # Period position (1-based) period_position: int # Period position (1-based)
period_count_total: int # Total number of periods period_count_total: int # Total number of periods
period_count_remaining: int # Remaining periods after this one period_count_remaining: int # Remaining periods after this one
# Relaxation information (priority 6 - only if period was relaxed) # Relaxation information (priority 7 - only if period was relaxed)
relaxation_active: bool # Whether this period was found via relaxation relaxation_active: bool # Whether this period was found via relaxation
relaxation_level: int # Relaxation level used (1-based) relaxation_level: int # Relaxation level used (1-based)
relaxation_threshold_original_pct: float # Original flex threshold (%) relaxation_threshold_original_pct: float # Original flex threshold (%)
@ -125,9 +131,10 @@ class PeriodAttributes(BaseAttributes, total=False):
2. Core decision attributes (level, rating_level, rating_difference_%) 2. Core decision attributes (level, rating_level, rating_difference_%)
3. Price statistics (price_mean, price_median, price_min, price_max, price_spread, volatility) 3. Price statistics (price_mean, price_median, price_min, price_max, price_spread, volatility)
4. Price comparison (period_price_diff_from_daily_min, period_price_diff_from_daily_min_%) 4. Price comparison (period_price_diff_from_daily_min, period_price_diff_from_daily_min_%)
5. Detail information (period_interval_count, period_position, period_count_total, period_count_remaining) 5. Day context (day_volatility_%, day_price_min, day_price_max, day_price_span)
6. Relaxation information (only if period was relaxed) 6. Detail information (period_interval_count, period_position, period_count_total, period_count_remaining)
7. Meta information (periods list) 7. Relaxation information (only if period was relaxed)
8. Meta information (periods list)
""" """
# Time information (priority 1) - start/end refer to current/next period # Time information (priority 1) - start/end refer to current/next period
@ -152,19 +159,25 @@ class PeriodAttributes(BaseAttributes, total=False):
period_price_diff_from_daily_min: float # Difference from daily min period_price_diff_from_daily_min: float # Difference from daily min
period_price_diff_from_daily_min_pct: float # Difference from daily min (%) period_price_diff_from_daily_min_pct: float # Difference from daily min (%)
# Detail information (priority 5) # Day context (priority 5)
day_volatility_pct: float | None # Volatility of the period's day (%), None for zero-average days
day_price_min: float # Daily minimum price in minor currency (ct/ore)
day_price_max: float # Daily maximum price in minor currency (ct/ore)
day_price_span: float # Daily price span in minor currency (ct/ore)
# Detail information (priority 6)
period_interval_count: int # Number of intervals in current/next period period_interval_count: int # Number of intervals in current/next period
period_position: int # Period position (1-based) period_position: int # Period position (1-based)
period_count_total: int # Total number of periods found period_count_total: int # Total number of periods found
period_count_remaining: int # Remaining periods after current/next one period_count_remaining: int # Remaining periods after current/next one
# Relaxation information (priority 6 - only if period was relaxed) # Relaxation information (priority 7 - only if period was relaxed)
relaxation_active: bool # Whether current/next period was found via relaxation relaxation_active: bool # Whether current/next period was found via relaxation
relaxation_level: int # Relaxation level used (1-based) relaxation_level: int # Relaxation level used (1-based)
relaxation_threshold_original_pct: float # Original flex threshold (%) relaxation_threshold_original_pct: float # Original flex threshold (%)
relaxation_threshold_applied_pct: float # Applied flex threshold after relaxation (%) relaxation_threshold_applied_pct: float # Applied flex threshold after relaxation (%)
# Meta information (priority 7) # Meta information (priority 8)
periods: list[PeriodSummary] # All periods found (sorted by start time) periods: list[PeriodSummary] # All periods found (sorted by start time)

View file

@ -871,7 +871,7 @@ class TibberPricesDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
return self._data_transformer.get_threshold_percentages() return self._data_transformer.get_threshold_percentages()
def _calculate_periods_for_price_info( def _calculate_periods_for_price_info(
self, price_info: dict[str, Any], day_patterns: dict[str, Any] | None = None self, price_info: list[dict[str, Any]], day_patterns: dict[str, Any] | None = None
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Calculate periods (best price and peak price) for the given price info.""" """Calculate periods (best price and peak price) for the given price info."""
return self._period_calculator.calculate_periods_for_price_info(price_info, day_patterns) return self._period_calculator.calculate_periods_for_price_info(price_info, day_patterns)

View file

@ -28,7 +28,7 @@ class TibberPricesDataTransformer:
self, self,
config_entry: ConfigEntry, config_entry: ConfigEntry,
log_prefix: str, log_prefix: str,
calculate_periods_fn: Callable[[dict[str, Any], dict[str, Any] | None], dict[str, Any]], calculate_periods_fn: Callable[[list[dict[str, Any]], dict[str, Any] | None], dict[str, Any]],
time: TibberPricesTimeService, time: TibberPricesTimeService,
) -> None: ) -> None:
"""Initialize the data transformer.""" """Initialize the data transformer."""

View file

@ -3,11 +3,13 @@
from __future__ import annotations from __future__ import annotations
import logging import logging
from typing import TYPE_CHECKING from typing import TYPE_CHECKING, Any
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
from .types import TibberPricesPeriodConfig
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
_LOGGER_DETAILS = logging.getLogger(__name__ + ".details") _LOGGER_DETAILS = logging.getLogger(__name__ + ".details")
@ -82,9 +84,9 @@ def recalculate_period_metadata(periods: list[dict], *, time: TibberPricesTimeSe
period["period_count_remaining"] = total_periods - position period["period_count_remaining"] = total_periods - position
def merge_adjacent_periods(period1: dict, period2: dict) -> dict: def _merge_adjacent_periods_from_summaries(period1: dict, period2: dict) -> dict:
""" """
Merge two adjacent or overlapping periods into one. Merge two adjacent or overlapping periods from summary data only.
The newer period's relaxation attributes override the older period's. The newer period's relaxation attributes override the older period's.
Takes the earliest start time and latest end time. Takes the earliest start time and latest end time.
@ -111,14 +113,6 @@ def merge_adjacent_periods(period1: dict, period2: dict) -> dict:
- period_interval_level_gap_count - period_interval_level_gap_count
- period_interval_smoothed_count - period_interval_smoothed_count
Args:
period1: First period (older baseline or relaxed period)
period2: Second period (newer relaxed period with higher flex)
Returns:
Merged period dict with combined time span, recomputed price extremes,
and the newer period's relaxation attributes.
""" """
# Take earliest start and latest end # Take earliest start and latest end
merged_start = min(period1["start"], period2["start"]) merged_start = min(period1["start"], period2["start"])
@ -205,6 +199,236 @@ def merge_adjacent_periods(period1: dict, period2: dict) -> dict:
return merged return merged
def _build_raw_merge_context(
all_prices: list[dict],
config: TibberPricesPeriodConfig,
*,
time: TibberPricesTimeService,
) -> dict[str, Any] | None:
"""Build reusable context for raw-interval merge recomputation."""
from .period_building import calculate_reference_prices, split_intervals_by_day # noqa: PLC0415
from .types import TibberPricesThresholdConfig # noqa: PLC0415
sorted_prices = sorted(
all_prices,
key=lambda price_data: time.get_interval_time(price_data) or time.now(),
)
interval_lookup: dict[Any, dict] = {}
for price_data in sorted_prices:
if (interval_start := time.get_interval_time(price_data)) is not None:
interval_lookup[interval_start] = price_data
if not interval_lookup:
return None
intervals_by_day, avg_price_by_day = split_intervals_by_day(sorted_prices, time=time)
ref_prices = calculate_reference_prices(intervals_by_day, reverse_sort=config.reverse_sort)
thresholds = TibberPricesThresholdConfig(
threshold_low=config.threshold_low,
threshold_high=config.threshold_high,
threshold_volatility_moderate=config.threshold_volatility_moderate,
threshold_volatility_high=config.threshold_volatility_high,
threshold_volatility_very_high=config.threshold_volatility_very_high,
reverse_sort=config.reverse_sort,
)
return {
"interval_duration": time.get_interval_duration(),
"interval_lookup": interval_lookup,
"price_context": {
"ref_prices": ref_prices,
"avg_prices": avg_price_by_day,
"intervals_by_day": intervals_by_day,
},
"thresholds": thresholds,
}
def _collect_period_price_data(
merged_start: Any,
merged_end: Any,
merge_context: dict[str, Any],
) -> list[dict] | None:
"""Collect the contiguous raw intervals for a merged period span."""
interval_lookup = merge_context["interval_lookup"]
interval_duration = merge_context["interval_duration"]
period_price_data: list[dict] = []
cursor = merged_start
while cursor < merged_end:
if (price_data := interval_lookup.get(cursor)) is None:
return None
period_price_data.append(price_data)
cursor += interval_duration
return period_price_data
def _rebuild_merged_period_from_raw(
period1: dict,
period2: dict,
merge_context: dict[str, Any],
) -> dict | None:
"""Rebuild merged period statistics from the raw interval union."""
from custom_components.tibber_prices.utils.price import ( # noqa: PLC0415
aggregate_period_levels,
aggregate_period_ratings,
calculate_coefficient_of_variation,
calculate_volatility_level,
)
from .period_statistics import ( # noqa: PLC0415
build_period_summary_dict,
calculate_aggregated_rating_difference,
calculate_period_price_diff,
calculate_period_price_statistics,
)
from .types import TibberPricesPeriodData, TibberPricesPeriodStatistics # noqa: PLC0415
merged_start = min(period1["start"], period2["start"])
merged_end = max(period1["end"], period2["end"])
period_price_data = _collect_period_price_data(merged_start, merged_end, merge_context)
if not period_price_data:
return None
thresholds = merge_context["thresholds"]
price_context = merge_context["price_context"]
aggregated_level = aggregate_period_levels(period_price_data)
aggregated_rating = None
if thresholds.threshold_low is not None and thresholds.threshold_high is not None:
aggregated_rating, _ = aggregate_period_ratings(
period_price_data,
thresholds.threshold_low,
thresholds.threshold_high,
)
price_stats = calculate_period_price_statistics(period_price_data)
period_price_diff, period_price_diff_pct = calculate_period_price_diff(
price_stats["price_mean"],
merged_start,
price_context,
)
prices_for_volatility = [float(price_data["total"]) for price_data in period_price_data if "total" in price_data]
period_cv = calculate_coefficient_of_variation(prices_for_volatility)
volatility = calculate_volatility_level(
prices_for_volatility,
threshold_moderate=thresholds.threshold_volatility_moderate,
threshold_high=thresholds.threshold_volatility_high,
threshold_very_high=thresholds.threshold_volatility_very_high,
).lower()
rating_difference_pct = calculate_aggregated_rating_difference(period_price_data)
merged = build_period_summary_dict(
TibberPricesPeriodData(
start_time=merged_start,
end_time=merged_end,
period_length=len(period_price_data),
period_idx=1,
total_periods=1,
),
TibberPricesPeriodStatistics(
aggregated_level=aggregated_level,
aggregated_rating=aggregated_rating,
rating_difference_pct=rating_difference_pct,
price_mean=price_stats["price_mean"],
price_median=price_stats["price_median"],
price_min=price_stats["price_min"],
price_max=price_stats["price_max"],
price_spread=price_stats["price_spread"],
volatility=volatility,
coefficient_of_variation=round(period_cv, 1) if period_cv is not None else None,
period_price_diff=period_price_diff,
period_price_diff_pct=period_price_diff_pct,
),
reverse_sort=thresholds.reverse_sort,
price_context=price_context,
)
if period1.get("relaxation_active") or period2.get("relaxation_active"):
merged["relaxation_active"] = True
for attr in (
"relaxation_level",
"relaxation_threshold_original_%",
"relaxation_threshold_applied_%",
"duration_fallback_active",
"duration_fallback_min_length",
):
if attr in period2:
merged[attr] = period2[attr]
elif attr in period1:
merged[attr] = period1[attr]
for attr in (
"period_interval_level_gap_count",
"period_interval_smoothed_count",
):
total = 0
has_value = False
for period in (period1, period2):
if (value := period.get(attr)) is not None:
total += int(value)
has_value = True
if has_value:
merged[attr] = total
merged["merged_from"] = {
"period1_start": period1["start"].isoformat(),
"period1_end": period1["end"].isoformat(),
"period2_start": period2["start"].isoformat(),
"period2_end": period2["end"].isoformat(),
}
_LOGGER_DETAILS.debug(
"%sMerged periods from raw intervals: %s-%s + %s-%s%s-%s (intervals: %d, mean: %s)",
INDENT_L2,
period1["start"].strftime("%H:%M"),
period1["end"].strftime("%H:%M"),
period2["start"].strftime("%H:%M"),
period2["end"].strftime("%H:%M"),
merged_start.strftime("%H:%M"),
merged_end.strftime("%H:%M"),
merged.get("period_interval_count"),
merged.get("price_mean"),
)
return merged
def merge_adjacent_periods(
period1: dict,
period2: dict,
*,
merge_context: dict[str, Any] | None = None,
) -> dict:
"""
Merge two adjacent or overlapping periods into one.
When raw interval data is available, rebuild the merged summary from the
underlying interval union so medians, CV, ratings, and interval counts stay
exact after overlap resolution. Falls back to the previous summary-based
approximation if the raw slice cannot be recovered.
"""
if merge_context is not None and (recomputed := _rebuild_merged_period_from_raw(period1, period2, merge_context)):
return recomputed
if merge_context is not None:
_LOGGER.debug(
"Falling back to summary-based merge for %s-%s + %s-%s",
period1["start"].strftime("%H:%M"),
period1["end"].strftime("%H:%M"),
period2["start"].strftime("%H:%M"),
period2["end"].strftime("%H:%M"),
)
return _merge_adjacent_periods_from_summaries(period1, period2)
def _check_merge_quality_gate(periods_to_merge: list[tuple[int, dict]], relaxed: dict) -> bool: def _check_merge_quality_gate(periods_to_merge: list[tuple[int, dict]], relaxed: dict) -> bool:
""" """
Check if merging would create a period that's too heterogeneous. Check if merging would create a period that's too heterogeneous.
@ -336,6 +560,10 @@ def _find_adjacent_or_overlapping(relaxed: dict, existing_periods: list[dict]) -
def resolve_period_overlaps( def resolve_period_overlaps(
existing_periods: list[dict], existing_periods: list[dict],
new_relaxed_periods: list[dict], new_relaxed_periods: list[dict],
*,
all_prices: list[dict] | None = None,
config: TibberPricesPeriodConfig | None = None,
time: TibberPricesTimeService | None = None,
) -> tuple[list[dict], int]: ) -> tuple[list[dict], int]:
""" """
Resolve overlaps between existing periods and newly found relaxed periods. Resolve overlaps between existing periods and newly found relaxed periods.
@ -355,6 +583,9 @@ def resolve_period_overlaps(
Args: Args:
existing_periods: All previously found periods (baseline + earlier relaxation phases) existing_periods: All previously found periods (baseline + earlier relaxation phases)
new_relaxed_periods: Periods found in current relaxation phase (will be merged if adjacent) new_relaxed_periods: Periods found in current relaxation phase (will be merged if adjacent)
all_prices: Optional raw interval data for exact merged-summary recomputation
config: Optional period config used to rebuild merged summaries from raw data
time: Optional time service for interval alignment during raw recomputation
Returns: Returns:
Tuple of (merged_periods, new_periods_count): Tuple of (merged_periods, new_periods_count):
@ -378,6 +609,10 @@ def resolve_period_overlaps(
merged = existing_periods.copy() merged = existing_periods.copy()
periods_added = 0 periods_added = 0
merge_context = None
if all_prices is not None and config is not None and time is not None:
merge_context = _build_raw_merge_context(all_prices, config, time=time)
for relaxed in new_relaxed_periods: for relaxed in new_relaxed_periods:
relaxed_start = relaxed["start"] relaxed_start = relaxed["start"]
@ -428,7 +663,7 @@ def resolve_period_overlaps(
# Remove old periods (in reverse order to maintain indices) # Remove old periods (in reverse order to maintain indices)
for idx, existing in reversed(periods_to_merge): for idx, existing in reversed(periods_to_merge):
merged_period = merge_adjacent_periods(existing, merged_period) merged_period = merge_adjacent_periods(existing, merged_period, merge_context=merge_context)
merged.pop(idx) merged.pop(idx)
# Add the merged result # Add the merged result

View file

@ -206,8 +206,10 @@ def build_period_summary_dict(
day_span = day_max - day_min day_span = day_max - day_min
day_avg = avg_prices.get(period_start_date, sum(day_prices) / len(day_prices)) day_avg = avg_prices.get(period_start_date, sum(day_prices) / len(day_prices))
# Calculate volatility percentage (span / avg * 100) # Calculate volatility percentage relative to the day's absolute average.
day_volatility_pct = round((day_span / day_avg * 100), 1) if day_avg > 0 else 0.0 # Negative-average days remain meaningful, while true zero-average days
# cannot produce a truthful percentage and therefore return None.
day_volatility_pct = round((day_span / abs(day_avg) * 100), 1) if day_avg != 0 else None
# Convert to minor units (ct/øre) for consistency with other price attributes # Convert to minor units (ct/øre) for consistency with other price attributes
summary["day_volatility_%"] = day_volatility_pct summary["day_volatility_%"] = day_volatility_pct

View file

@ -283,6 +283,7 @@ def _try_min_duration_fallback(
*, *,
config: TibberPricesPeriodConfig, config: TibberPricesPeriodConfig,
existing_periods: list[dict], existing_periods: list[dict],
all_prices: list[dict],
prices_by_day: dict[date, list[dict]], prices_by_day: dict[date, list[dict]],
time: TibberPricesTimeService, time: TibberPricesTimeService,
max_relaxation_attempts: int = 0, max_relaxation_attempts: int = 0,
@ -438,6 +439,9 @@ def _try_min_duration_fallback(
merged_periods, _new_count = resolve_period_overlaps( merged_periods, _new_count = resolve_period_overlaps(
existing_periods, existing_periods,
fallback_periods, fallback_periods,
all_prices=all_prices,
config=config,
time=time,
) )
recalculate_period_metadata(merged_periods, time=time) recalculate_period_metadata(merged_periods, time=time)
@ -836,6 +840,7 @@ def calculate_periods_with_relaxation(
fallback_result, fallback_metadata = _try_min_duration_fallback( fallback_result, fallback_metadata = _try_min_duration_fallback(
config=config, config=config,
existing_periods=all_periods, existing_periods=all_periods,
all_prices=all_prices,
prices_by_day=prices_by_day, prices_by_day=prices_by_day,
time=time, time=time,
max_relaxation_attempts=max_relaxation_attempts, max_relaxation_attempts=max_relaxation_attempts,
@ -1018,6 +1023,9 @@ def relax_all_prices(
combined, standalone_count = resolve_period_overlaps( combined, standalone_count = resolve_period_overlaps(
existing_periods=existing_periods, existing_periods=existing_periods,
new_relaxed_periods=new_periods, new_relaxed_periods=new_periods,
all_prices=all_prices,
config=config,
time=time,
) )
# Count periods per day with QUALITY GATE check # Count periods per day with QUALITY GATE check

View file

@ -74,6 +74,54 @@ class TibberPricesPeriodCalculator:
section = self.config_entry.options.get(config_section, {}) section = self.config_entry.options.get(config_section, {})
return section.get(config_key, default) return section.get(config_key, default)
def _normalize_float_option(
self,
value: Any,
default: float,
*,
option_name: str,
absolute: bool = False,
divisor: float = 1.0,
) -> float:
"""Normalize numeric config values and fall back cleanly on invalid input."""
try:
normalized = float(value)
except TypeError, ValueError:
self._log("warning", "Invalid numeric option %s=%r, using default %s", option_name, value, default)
normalized = float(default)
if absolute:
normalized = abs(normalized)
return normalized / divisor
def _normalize_int_option(
self,
value: Any,
default: int,
*,
option_name: str,
minimum: int | None = None,
) -> int:
"""Normalize integer config values and fall back cleanly on invalid input."""
try:
normalized = int(value)
except TypeError, ValueError:
self._log("warning", "Invalid integer option %s=%r, using default %s", option_name, value, default)
return default
if minimum is not None and normalized < minimum:
self._log(
"warning",
"Out-of-range integer option %s=%r, using default %s",
option_name,
value,
default,
)
return default
return normalized
def _log(self, level: str, message: str, *args: object, **kwargs: object) -> None: def _log(self, level: str, message: str, *args: object, **kwargs: object) -> None:
"""Log with calculator-specific prefix.""" """Log with calculator-specific prefix."""
prefixed_message = f"{self._log_prefix} {message}" prefixed_message = f"{self._log_prefix} {message}"
@ -88,12 +136,12 @@ class TibberPricesPeriodCalculator:
self._last_periods_hash = None self._last_periods_hash = None
self._log("debug", "Period config cache and calculation cache invalidated") self._log("debug", "Period config cache and calculation cache invalidated")
def _compute_periods_hash(self, price_info: dict[str, Any]) -> str: def _compute_periods_hash(self, price_info: list[dict[str, Any]]) -> str:
""" """
Compute hash of price data and config for period calculation caching. Compute hash of price data and config for period calculation caching.
Only includes data that affects period calculation: Only includes data that affects period calculation:
- All interval timestamps and enriched rating levels (yesterday/today/tomorrow) - Today/tomorrow interval content (timestamps, totals, levels, ratings, differences)
- Period calculation config (flex, min_distance, min_period_length) - Period calculation config (flex, min_distance, min_period_length)
- Level filter overrides - Level filter overrides
@ -101,20 +149,42 @@ class TibberPricesPeriodCalculator:
Hash string for cache key comparison. Hash string for cache key comparison.
""" """
# Get today and tomorrow intervals for hash calculation # Get today and tomorrow intervals for hash calculation.
# CRITICAL: Only today+tomorrow needed in hash because: # Hash full interval signatures instead of only the first startsAt so we also
# 1. Mitternacht: "today" startsAt changes → cache invalidates # invalidate when prices or enriched levels change within the same calendar day.
# 2. Tomorrow arrival: "tomorrow" startsAt changes from None → cache invalidates
# 3. Yesterday/day-before-yesterday are static (rating_levels don't change retroactively)
# 4. Using first startsAt as representative (changes → entire day changed)
coordinator_data = {"priceInfo": price_info} coordinator_data = {"priceInfo": price_info}
today_intervals = get_intervals_for_day_offsets(coordinator_data, [0]) today_intervals = get_intervals_for_day_offsets(coordinator_data, [0])
tomorrow_intervals = get_intervals_for_day_offsets(coordinator_data, [1]) tomorrow_intervals = get_intervals_for_day_offsets(coordinator_data, [1])
# Use first startsAt of each day as representative for entire day's data def _build_interval_signature(intervals: list[dict[str, Any]]) -> tuple[tuple[Any, Any, Any, Any, Any], ...]:
# If day is empty, use None (detects data availability changes) signature: list[tuple[Any, Any, Any, Any, Any]] = []
today_start = today_intervals[0].get("startsAt") if today_intervals else None
tomorrow_start = tomorrow_intervals[0].get("startsAt") if tomorrow_intervals else None for interval in intervals:
starts_at = interval.get("startsAt")
starts_at_key = (
starts_at.isoformat() if starts_at is not None and hasattr(starts_at, "isoformat") else starts_at
)
total = interval.get("total")
total_key = round(float(total), 6) if total is not None else None
difference = interval.get("difference")
difference_key = round(float(difference), 6) if difference is not None else None
signature.append(
(
starts_at_key,
total_key,
interval.get("level"),
interval.get("rating_level"),
difference_key,
)
)
return tuple(signature)
today_signature = _build_interval_signature(today_intervals)
tomorrow_signature = _build_interval_signature(tomorrow_intervals)
# Get period configs (both best and peak) # Get period configs (both best and peak)
best_config = self.get_period_config(reverse_sort=False) best_config = self.get_period_config(reverse_sort=False)
@ -128,8 +198,8 @@ class TibberPricesPeriodCalculator:
# Compute hash from all relevant data # Compute hash from all relevant data
hash_data = ( hash_data = (
today_start, # Representative for today's data (changes at midnight) today_signature,
tomorrow_start, # Representative for tomorrow's data (changes when data arrives) tomorrow_signature,
tuple(best_config.items()), tuple(best_config.items()),
tuple(peak_config.items()), tuple(peak_config.items()),
best_level_filter, best_level_filter,
@ -195,32 +265,51 @@ class TibberPricesPeriodCalculator:
_const.DEFAULT_BEST_PRICE_MIN_PERIOD_LENGTH, _const.DEFAULT_BEST_PRICE_MIN_PERIOD_LENGTH,
) )
# Convert flex from percentage to decimal (e.g., 5 -> 0.05) default_flex = _const.DEFAULT_PEAK_PRICE_FLEX if reverse_sort else _const.DEFAULT_BEST_PRICE_FLEX
# CRITICAL: Normalize to absolute value for internal calculations default_min_distance = (
# User-facing values use sign convention: _const.DEFAULT_PEAK_PRICE_MIN_DISTANCE_FROM_AVG
# - Best price: positive (e.g., +15% above minimum) if reverse_sort
# - Peak price: negative (e.g., -20% below maximum) else _const.DEFAULT_BEST_PRICE_MIN_DISTANCE_FROM_AVG
# Internal calculations always use positive values with reverse_sort flag )
try: default_min_period_length = (
flex = abs(float(flex)) / 100 # Always positive internally _const.DEFAULT_PEAK_PRICE_MIN_PERIOD_LENGTH if reverse_sort else _const.DEFAULT_BEST_PRICE_MIN_PERIOD_LENGTH
except TypeError, ValueError:
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 # Convert flex from percentage to decimal (e.g., 5 -> 0.05)
# User-facing values use sign convention: # and normalize sign conventions to positive internal values.
# - Best price: negative (e.g., -5% below average) flex = self._normalize_float_option(
# - Peak price: positive (e.g., +5% above average) flex,
# Internal calculations always use positive values with reverse_sort flag default_flex,
min_distance_from_avg_normalized = abs(float(min_distance_from_avg)) option_name=_const.CONF_PEAK_PRICE_FLEX if reverse_sort else _const.CONF_BEST_PRICE_FLEX,
absolute=True,
divisor=100,
)
# CRITICAL: Normalize min_distance_from_avg to absolute value.
min_distance_from_avg_normalized = self._normalize_float_option(
min_distance_from_avg,
default_min_distance,
option_name=(
_const.CONF_PEAK_PRICE_MIN_DISTANCE_FROM_AVG
if reverse_sort
else _const.CONF_BEST_PRICE_MIN_DISTANCE_FROM_AVG
),
absolute=True,
)
config = { config = {
"flex": flex, "flex": flex,
"min_distance_from_avg": min_distance_from_avg_normalized, "min_distance_from_avg": min_distance_from_avg_normalized,
"min_period_length": int(min_period_length), "min_period_length": self._normalize_int_option(
min_period_length,
default_min_period_length,
option_name=(
_const.CONF_PEAK_PRICE_MIN_PERIOD_LENGTH
if reverse_sort
else _const.CONF_BEST_PRICE_MIN_PERIOD_LENGTH
),
minimum=1,
),
} }
# Extension settings (stored in 'extension_settings' nested section) # Extension settings (stored in 'extension_settings' nested section)
@ -232,12 +321,15 @@ class TibberPricesPeriodCalculator:
_const.DEFAULT_PEAK_PRICE_EXTEND_TO_VERY_EXPENSIVE, _const.DEFAULT_PEAK_PRICE_EXTEND_TO_VERY_EXPENSIVE,
) )
) )
max_extension_intervals = int( max_extension_intervals = self._normalize_int_option(
self._get_option( self._get_option(
_const.CONF_PEAK_PRICE_MAX_EXTENSION_INTERVALS, _const.CONF_PEAK_PRICE_MAX_EXTENSION_INTERVALS,
"extension_settings", "extension_settings",
_const.DEFAULT_PEAK_PRICE_MAX_EXTENSION_INTERVALS, _const.DEFAULT_PEAK_PRICE_MAX_EXTENSION_INTERVALS,
) ),
_const.DEFAULT_PEAK_PRICE_MAX_EXTENSION_INTERVALS,
option_name=_const.CONF_PEAK_PRICE_MAX_EXTENSION_INTERVALS,
minimum=0,
) )
else: else:
extend_to_extreme = bool( extend_to_extreme = bool(
@ -247,12 +339,15 @@ class TibberPricesPeriodCalculator:
_const.DEFAULT_BEST_PRICE_EXTEND_TO_VERY_CHEAP, _const.DEFAULT_BEST_PRICE_EXTEND_TO_VERY_CHEAP,
) )
) )
max_extension_intervals = int( max_extension_intervals = self._normalize_int_option(
self._get_option( self._get_option(
_const.CONF_BEST_PRICE_MAX_EXTENSION_INTERVALS, _const.CONF_BEST_PRICE_MAX_EXTENSION_INTERVALS,
"extension_settings", "extension_settings",
_const.DEFAULT_BEST_PRICE_MAX_EXTENSION_INTERVALS, _const.DEFAULT_BEST_PRICE_MAX_EXTENSION_INTERVALS,
) ),
_const.DEFAULT_BEST_PRICE_MAX_EXTENSION_INTERVALS,
option_name=_const.CONF_BEST_PRICE_MAX_EXTENSION_INTERVALS,
minimum=0,
) )
config["extend_to_extreme"] = extend_to_extreme config["extend_to_extreme"] = extend_to_extreme
@ -260,20 +355,26 @@ class TibberPricesPeriodCalculator:
# Geometric flex bonus (intervals inside valley/peak zone get extra flex) # Geometric flex bonus (intervals inside valley/peak zone get extra flex)
if reverse_sort: if reverse_sort:
geometric_flex_pct = int( geometric_flex_pct = self._normalize_int_option(
self._get_option( self._get_option(
_const.CONF_PEAK_PRICE_GEOMETRIC_FLEX, _const.CONF_PEAK_PRICE_GEOMETRIC_FLEX,
"extension_settings", "extension_settings",
_const.DEFAULT_PEAK_PRICE_GEOMETRIC_FLEX, _const.DEFAULT_PEAK_PRICE_GEOMETRIC_FLEX,
) ),
_const.DEFAULT_PEAK_PRICE_GEOMETRIC_FLEX,
option_name=_const.CONF_PEAK_PRICE_GEOMETRIC_FLEX,
minimum=0,
) )
else: else:
geometric_flex_pct = int( geometric_flex_pct = self._normalize_int_option(
self._get_option( self._get_option(
_const.CONF_BEST_PRICE_GEOMETRIC_FLEX, _const.CONF_BEST_PRICE_GEOMETRIC_FLEX,
"extension_settings", "extension_settings",
_const.DEFAULT_BEST_PRICE_GEOMETRIC_FLEX, _const.DEFAULT_BEST_PRICE_GEOMETRIC_FLEX,
) ),
_const.DEFAULT_BEST_PRICE_GEOMETRIC_FLEX,
option_name=_const.CONF_BEST_PRICE_GEOMETRIC_FLEX,
minimum=0,
) )
config["geometric_extra_flex"] = geometric_flex_pct / 100 config["geometric_extra_flex"] = geometric_flex_pct / 100
@ -286,12 +387,15 @@ class TibberPricesPeriodCalculator:
_const.DEFAULT_PEAK_PRICE_SEGMENT_FORCING, _const.DEFAULT_PEAK_PRICE_SEGMENT_FORCING,
) )
) )
segment_min_periods = int( segment_min_periods = self._normalize_int_option(
self._get_option( self._get_option(
_const.CONF_PEAK_PRICE_SEGMENT_MIN_PERIODS, _const.CONF_PEAK_PRICE_SEGMENT_MIN_PERIODS,
"extension_settings", "extension_settings",
_const.DEFAULT_PEAK_PRICE_SEGMENT_MIN_PERIODS, _const.DEFAULT_PEAK_PRICE_SEGMENT_MIN_PERIODS,
) ),
_const.DEFAULT_PEAK_PRICE_SEGMENT_MIN_PERIODS,
option_name=_const.CONF_PEAK_PRICE_SEGMENT_MIN_PERIODS,
minimum=1,
) )
else: else:
segment_forcing = bool( segment_forcing = bool(
@ -301,12 +405,15 @@ class TibberPricesPeriodCalculator:
_const.DEFAULT_BEST_PRICE_SEGMENT_FORCING, _const.DEFAULT_BEST_PRICE_SEGMENT_FORCING,
) )
) )
segment_min_periods = int( segment_min_periods = self._normalize_int_option(
self._get_option( self._get_option(
_const.CONF_BEST_PRICE_SEGMENT_MIN_PERIODS, _const.CONF_BEST_PRICE_SEGMENT_MIN_PERIODS,
"extension_settings", "extension_settings",
_const.DEFAULT_BEST_PRICE_SEGMENT_MIN_PERIODS, _const.DEFAULT_BEST_PRICE_SEGMENT_MIN_PERIODS,
) ),
_const.DEFAULT_BEST_PRICE_SEGMENT_MIN_PERIODS,
option_name=_const.CONF_BEST_PRICE_SEGMENT_MIN_PERIODS,
minimum=1,
) )
config["segment_forcing"] = segment_forcing config["segment_forcing"] = segment_forcing
config["segment_min_periods"] = segment_min_periods config["segment_min_periods"] = segment_min_periods
@ -318,7 +425,7 @@ class TibberPricesPeriodCalculator:
def should_show_periods( def should_show_periods(
self, self,
price_info: dict[str, Any], price_info: list[dict[str, Any]],
*, *,
reverse_sort: bool, reverse_sort: bool,
level_override: str | None = None, level_override: str | None = None,
@ -327,7 +434,7 @@ class TibberPricesPeriodCalculator:
Check if periods should be shown based on level filter only. Check if periods should be shown based on level filter only.
Args: Args:
price_info: Price information dict with today/yesterday/tomorrow data price_info: Flat list of price intervals (yesterday/today/tomorrow)
reverse_sort: If False (best_price), checks max_level filter. reverse_sort: If False (best_price), checks max_level filter.
If True (peak_price), checks min_level filter. If True (peak_price), checks min_level filter.
level_override: Optional override for level filter ("any" to disable) level_override: Optional override for level filter ("any" to disable)
@ -486,16 +593,27 @@ class TibberPricesPeriodCalculator:
# Normal check failed - try splitting at gap clusters as fallback # Normal check failed - try splitting at gap clusters as fallback
# Get minimum period length from config (convert minutes to intervals) # Get minimum period length from config (convert minutes to intervals)
period_settings = self.config_entry.options.get("period_settings", {})
if reverse_sort: if reverse_sort:
min_period_minutes = period_settings.get( min_period_minutes = self._normalize_int_option(
self._get_option(
_const.CONF_PEAK_PRICE_MIN_PERIOD_LENGTH, _const.CONF_PEAK_PRICE_MIN_PERIOD_LENGTH,
"period_settings",
_const.DEFAULT_PEAK_PRICE_MIN_PERIOD_LENGTH, _const.DEFAULT_PEAK_PRICE_MIN_PERIOD_LENGTH,
),
_const.DEFAULT_PEAK_PRICE_MIN_PERIOD_LENGTH,
option_name=_const.CONF_PEAK_PRICE_MIN_PERIOD_LENGTH,
minimum=1,
) )
else: else:
min_period_minutes = period_settings.get( min_period_minutes = self._normalize_int_option(
self._get_option(
_const.CONF_BEST_PRICE_MIN_PERIOD_LENGTH, _const.CONF_BEST_PRICE_MIN_PERIOD_LENGTH,
"period_settings",
_const.DEFAULT_BEST_PRICE_MIN_PERIOD_LENGTH, _const.DEFAULT_BEST_PRICE_MIN_PERIOD_LENGTH,
),
_const.DEFAULT_BEST_PRICE_MIN_PERIOD_LENGTH,
option_name=_const.CONF_BEST_PRICE_MIN_PERIOD_LENGTH,
minimum=1,
) )
min_period_intervals = self.time.minutes_to_intervals(min_period_minutes) min_period_intervals = self.time.minutes_to_intervals(min_period_minutes)
@ -590,7 +708,7 @@ class TibberPricesPeriodCalculator:
def check_level_filter( def check_level_filter(
self, self,
price_info: dict[str, Any], price_info: list[dict[str, Any]],
*, *,
reverse_sort: bool, reverse_sort: bool,
override: str | None = None, override: str | None = None,
@ -602,7 +720,7 @@ class TibberPricesPeriodCalculator:
to deviate by one level step (e.g., CHEAP allows NORMAL, but not EXPENSIVE). to deviate by one level step (e.g., CHEAP allows NORMAL, but not EXPENSIVE).
Args: Args:
price_info: Price information dict with today data price_info: Flat list of price intervals used for today's level check
reverse_sort: If False (best_price), checks max_level (upper bound filter). reverse_sort: If False (best_price), checks max_level (upper bound filter).
If True (peak_price), checks min_level (lower bound filter). If True (peak_price), checks min_level (lower bound filter).
override: Optional override value (e.g., "any" to disable filter) override: Optional override value (e.g., "any" to disable filter)
@ -644,16 +762,27 @@ class TibberPricesPeriodCalculator:
return True # If no data, don't filter return True # If no data, don't filter
# Get gap tolerance configuration # Get gap tolerance configuration
period_settings = self.config_entry.options.get("period_settings", {})
if reverse_sort: if reverse_sort:
max_gap_count = period_settings.get( max_gap_count = self._normalize_int_option(
self._get_option(
_const.CONF_PEAK_PRICE_MAX_LEVEL_GAP_COUNT, _const.CONF_PEAK_PRICE_MAX_LEVEL_GAP_COUNT,
"period_settings",
_const.DEFAULT_PEAK_PRICE_MAX_LEVEL_GAP_COUNT, _const.DEFAULT_PEAK_PRICE_MAX_LEVEL_GAP_COUNT,
),
_const.DEFAULT_PEAK_PRICE_MAX_LEVEL_GAP_COUNT,
option_name=_const.CONF_PEAK_PRICE_MAX_LEVEL_GAP_COUNT,
minimum=0,
) )
else: else:
max_gap_count = period_settings.get( max_gap_count = self._normalize_int_option(
self._get_option(
_const.CONF_BEST_PRICE_MAX_LEVEL_GAP_COUNT, _const.CONF_BEST_PRICE_MAX_LEVEL_GAP_COUNT,
"period_settings",
_const.DEFAULT_BEST_PRICE_MAX_LEVEL_GAP_COUNT, _const.DEFAULT_BEST_PRICE_MAX_LEVEL_GAP_COUNT,
),
_const.DEFAULT_BEST_PRICE_MAX_LEVEL_GAP_COUNT,
option_name=_const.CONF_BEST_PRICE_MAX_LEVEL_GAP_COUNT,
minimum=0,
) )
# Note: level_config is lowercase from selector, but _const.PRICE_LEVEL_MAPPING uses uppercase # Note: level_config is lowercase from selector, but _const.PRICE_LEVEL_MAPPING uses uppercase
@ -683,7 +812,7 @@ class TibberPricesPeriodCalculator:
def calculate_periods_for_price_info( def calculate_periods_for_price_info(
self, self,
price_info: dict[str, Any], price_info: list[dict[str, Any]],
day_patterns: dict[str, Any] | None = None, day_patterns: dict[str, Any] | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
""" """
@ -724,28 +853,48 @@ class TibberPricesPeriodCalculator:
# Get rating thresholds from config (flat in options, not in sections) # Get rating thresholds from config (flat in options, not in sections)
# CRITICAL: Price rating thresholds are stored FLAT in options (no sections) # CRITICAL: Price rating thresholds are stored FLAT in options (no sections)
threshold_low = self.config_entry.options.get( threshold_low = self._normalize_float_option(
self.config_entry.options.get(
_const.CONF_PRICE_RATING_THRESHOLD_LOW, _const.CONF_PRICE_RATING_THRESHOLD_LOW,
_const.DEFAULT_PRICE_RATING_THRESHOLD_LOW, _const.DEFAULT_PRICE_RATING_THRESHOLD_LOW,
),
_const.DEFAULT_PRICE_RATING_THRESHOLD_LOW,
option_name=_const.CONF_PRICE_RATING_THRESHOLD_LOW,
) )
threshold_high = self.config_entry.options.get( threshold_high = self._normalize_float_option(
self.config_entry.options.get(
_const.CONF_PRICE_RATING_THRESHOLD_HIGH, _const.CONF_PRICE_RATING_THRESHOLD_HIGH,
_const.DEFAULT_PRICE_RATING_THRESHOLD_HIGH, _const.DEFAULT_PRICE_RATING_THRESHOLD_HIGH,
),
_const.DEFAULT_PRICE_RATING_THRESHOLD_HIGH,
option_name=_const.CONF_PRICE_RATING_THRESHOLD_HIGH,
) )
# Get volatility thresholds from config (flat in options, not in sections) # Get volatility thresholds from config (flat in options, not in sections)
# CRITICAL: Volatility thresholds are stored FLAT in options (no sections) # CRITICAL: Volatility thresholds are stored FLAT in options (no sections)
threshold_volatility_moderate = self.config_entry.options.get( threshold_volatility_moderate = self._normalize_float_option(
self.config_entry.options.get(
_const.CONF_VOLATILITY_THRESHOLD_MODERATE, _const.CONF_VOLATILITY_THRESHOLD_MODERATE,
_const.DEFAULT_VOLATILITY_THRESHOLD_MODERATE, _const.DEFAULT_VOLATILITY_THRESHOLD_MODERATE,
),
_const.DEFAULT_VOLATILITY_THRESHOLD_MODERATE,
option_name=_const.CONF_VOLATILITY_THRESHOLD_MODERATE,
) )
threshold_volatility_high = self.config_entry.options.get( threshold_volatility_high = self._normalize_float_option(
self.config_entry.options.get(
_const.CONF_VOLATILITY_THRESHOLD_HIGH, _const.CONF_VOLATILITY_THRESHOLD_HIGH,
_const.DEFAULT_VOLATILITY_THRESHOLD_HIGH, _const.DEFAULT_VOLATILITY_THRESHOLD_HIGH,
),
_const.DEFAULT_VOLATILITY_THRESHOLD_HIGH,
option_name=_const.CONF_VOLATILITY_THRESHOLD_HIGH,
) )
threshold_volatility_very_high = self.config_entry.options.get( threshold_volatility_very_high = self._normalize_float_option(
self.config_entry.options.get(
_const.CONF_VOLATILITY_THRESHOLD_VERY_HIGH, _const.CONF_VOLATILITY_THRESHOLD_VERY_HIGH,
_const.DEFAULT_VOLATILITY_THRESHOLD_VERY_HIGH, _const.DEFAULT_VOLATILITY_THRESHOLD_VERY_HIGH,
),
_const.DEFAULT_VOLATILITY_THRESHOLD_VERY_HIGH,
option_name=_const.CONF_VOLATILITY_THRESHOLD_VERY_HIGH,
) )
# Get relaxation configuration for best price # Get relaxation configuration for best price
@ -764,15 +913,25 @@ class TibberPricesPeriodCalculator:
show_best_price = bool(all_prices) show_best_price = bool(all_prices)
else: else:
show_best_price = self.should_show_periods(price_info, reverse_sort=False) if all_prices else False show_best_price = self.should_show_periods(price_info, reverse_sort=False) if all_prices else False
min_periods_best = self._get_option( min_periods_best = self._normalize_int_option(
self._get_option(
_const.CONF_MIN_PERIODS_BEST, _const.CONF_MIN_PERIODS_BEST,
"relaxation_and_target_periods", "relaxation_and_target_periods",
_const.DEFAULT_MIN_PERIODS_BEST, _const.DEFAULT_MIN_PERIODS_BEST,
),
_const.DEFAULT_MIN_PERIODS_BEST,
option_name=_const.CONF_MIN_PERIODS_BEST,
minimum=1,
) )
relaxation_attempts_best = self._get_option( relaxation_attempts_best = self._normalize_int_option(
self._get_option(
_const.CONF_RELAXATION_ATTEMPTS_BEST, _const.CONF_RELAXATION_ATTEMPTS_BEST,
"relaxation_and_target_periods", "relaxation_and_target_periods",
_const.DEFAULT_RELAXATION_ATTEMPTS_BEST, _const.DEFAULT_RELAXATION_ATTEMPTS_BEST,
),
_const.DEFAULT_RELAXATION_ATTEMPTS_BEST,
option_name=_const.CONF_RELAXATION_ATTEMPTS_BEST,
minimum=1,
) )
# Calculate best price periods (or return empty if filtered) # Calculate best price periods (or return empty if filtered)
@ -785,10 +944,15 @@ class TibberPricesPeriodCalculator:
"period_settings", "period_settings",
_const.DEFAULT_BEST_PRICE_MAX_LEVEL, _const.DEFAULT_BEST_PRICE_MAX_LEVEL,
) )
gap_count_best = self._get_option( gap_count_best = self._normalize_int_option(
self._get_option(
_const.CONF_BEST_PRICE_MAX_LEVEL_GAP_COUNT, _const.CONF_BEST_PRICE_MAX_LEVEL_GAP_COUNT,
"period_settings", "period_settings",
_const.DEFAULT_BEST_PRICE_MAX_LEVEL_GAP_COUNT, _const.DEFAULT_BEST_PRICE_MAX_LEVEL_GAP_COUNT,
),
_const.DEFAULT_BEST_PRICE_MAX_LEVEL_GAP_COUNT,
option_name=_const.CONF_BEST_PRICE_MAX_LEVEL_GAP_COUNT,
minimum=0,
) )
best_period_config = TibberPricesPeriodConfig( best_period_config = TibberPricesPeriodConfig(
reverse_sort=False, reverse_sort=False,
@ -851,15 +1015,25 @@ class TibberPricesPeriodCalculator:
show_peak_price = bool(all_prices) show_peak_price = bool(all_prices)
else: else:
show_peak_price = self.should_show_periods(price_info, reverse_sort=True) if all_prices else False show_peak_price = self.should_show_periods(price_info, reverse_sort=True) if all_prices else False
min_periods_peak = self._get_option( min_periods_peak = self._normalize_int_option(
self._get_option(
_const.CONF_MIN_PERIODS_PEAK, _const.CONF_MIN_PERIODS_PEAK,
"relaxation_and_target_periods", "relaxation_and_target_periods",
_const.DEFAULT_MIN_PERIODS_PEAK, _const.DEFAULT_MIN_PERIODS_PEAK,
),
_const.DEFAULT_MIN_PERIODS_PEAK,
option_name=_const.CONF_MIN_PERIODS_PEAK,
minimum=1,
) )
relaxation_attempts_peak = self._get_option( relaxation_attempts_peak = self._normalize_int_option(
self._get_option(
_const.CONF_RELAXATION_ATTEMPTS_PEAK, _const.CONF_RELAXATION_ATTEMPTS_PEAK,
"relaxation_and_target_periods", "relaxation_and_target_periods",
_const.DEFAULT_RELAXATION_ATTEMPTS_PEAK, _const.DEFAULT_RELAXATION_ATTEMPTS_PEAK,
),
_const.DEFAULT_RELAXATION_ATTEMPTS_PEAK,
option_name=_const.CONF_RELAXATION_ATTEMPTS_PEAK,
minimum=1,
) )
# Calculate peak price periods (or return empty if filtered) # Calculate peak price periods (or return empty if filtered)
@ -872,10 +1046,15 @@ class TibberPricesPeriodCalculator:
"period_settings", "period_settings",
_const.DEFAULT_PEAK_PRICE_MIN_LEVEL, _const.DEFAULT_PEAK_PRICE_MIN_LEVEL,
) )
gap_count_peak = self._get_option( gap_count_peak = self._normalize_int_option(
self._get_option(
_const.CONF_PEAK_PRICE_MAX_LEVEL_GAP_COUNT, _const.CONF_PEAK_PRICE_MAX_LEVEL_GAP_COUNT,
"period_settings", "period_settings",
_const.DEFAULT_PEAK_PRICE_MAX_LEVEL_GAP_COUNT, _const.DEFAULT_PEAK_PRICE_MAX_LEVEL_GAP_COUNT,
),
_const.DEFAULT_PEAK_PRICE_MAX_LEVEL_GAP_COUNT,
option_name=_const.CONF_PEAK_PRICE_MAX_LEVEL_GAP_COUNT,
minimum=0,
) )
peak_period_config = TibberPricesPeriodConfig( peak_period_config = TibberPricesPeriodConfig(
reverse_sort=True, reverse_sort=True,

View file

@ -0,0 +1,71 @@
"""Regression tests for visible best/peak period binary sensor attributes."""
from __future__ import annotations
from unittest.mock import Mock
import pytest
from custom_components.tibber_prices.binary_sensor.attributes import build_final_attributes_simple
from custom_components.tibber_prices.const import CONF_CURRENCY_DISPLAY_MODE, DISPLAY_MODE_BASE
from custom_components.tibber_prices.coordinator.time_service import TibberPricesTimeService
from homeassistant.util import dt as dt_util
def _dt(value: str):
"""Parse a timezone-aware datetime string for tests."""
parsed = dt_util.parse_datetime(value)
assert parsed is not None
return parsed
@pytest.mark.unit
def test_build_final_attributes_exposes_day_statistics_on_current_period() -> None:
"""Day-level period context should be exposed on the visible binary sensor attrs."""
current_period = {
"start": _dt("2025-11-22T12:00:00+01:00"),
"end": _dt("2025-11-22T13:00:00+01:00"),
"duration_minutes": 60,
"level": "CHEAP",
"rating_level": "LOW",
"rating_difference_%": -15.0,
"price_mean": -0.1,
"price_median": -0.12,
"price_min": -0.3,
"price_max": 0.1,
"price_spread": 0.4,
"price_coefficient_variation_%": 12.3,
"volatility": "moderate",
"period_price_diff_from_daily_min": 0.2,
"period_price_diff_from_daily_min_%": 200.0,
"day_volatility_%": 400.0,
"day_price_min": -30.0,
"day_price_max": 10.0,
"day_price_span": 40.0,
"period_interval_count": 4,
"period_position": 1,
"period_count_total": 1,
"period_count_remaining": 0,
}
time = TibberPricesTimeService(reference_time=_dt("2025-11-22T12:00:00+01:00"))
config_entry = Mock(options={CONF_CURRENCY_DISPLAY_MODE: DISPLAY_MODE_BASE})
attributes = build_final_attributes_simple(
current_period,
[current_period],
time=time,
config_entry=config_entry,
)
assert attributes["price_mean"] == -0.1
assert attributes["period_price_diff_from_daily_min"] == 0.2
assert attributes["day_volatility_%"] == 400.0
assert attributes["day_price_min"] == -30.0
assert attributes["day_price_max"] == 10.0
assert attributes["day_price_span"] == 40.0
nested_period = attributes["periods"][0]
assert nested_period["day_volatility_%"] == 400.0
assert nested_period["day_price_min"] == -30.0
assert nested_period["day_price_max"] == 10.0
assert nested_period["day_price_span"] == 40.0

View file

@ -0,0 +1,134 @@
"""Regression tests for overlap resolution and merged period summaries."""
from __future__ import annotations
from datetime import timedelta
import pytest
from custom_components.tibber_prices.coordinator.period_handlers import TibberPricesPeriodConfig
from custom_components.tibber_prices.coordinator.period_handlers.period_overlap import resolve_period_overlaps
from custom_components.tibber_prices.coordinator.time_service import TibberPricesTimeService
from custom_components.tibber_prices.utils.price import calculate_coefficient_of_variation
from homeassistant.util import dt as dt_util
def _create_interval(base_time, offset: int, price: float, level: str, difference: float, rating: str) -> dict:
"""Create one quarter-hour interval for overlap tests."""
return {
"startsAt": base_time + timedelta(minutes=offset * 15),
"total": price,
"level": level,
"difference": difference,
"rating_level": rating,
}
@pytest.mark.unit
class TestResolvePeriodOverlaps:
"""Validate merged period summaries stay consistent after overlap resolution."""
def test_merge_recomputes_summary_from_raw_intervals(self) -> None:
"""Overlapping periods should be rebuilt from the raw union, not glued summaries."""
base_time = dt_util.parse_datetime("2025-11-22T10:00:00+01:00")
assert base_time is not None
all_prices = [
_create_interval(base_time, 0, 0.10, "CHEAP", -12.0, "LOW"),
_create_interval(base_time, 1, 0.10, "CHEAP", -11.0, "LOW"),
_create_interval(base_time, 2, 0.11, "CHEAP", -10.0, "LOW"),
_create_interval(base_time, 3, 0.11, "CHEAP", -9.0, "NORMAL"),
_create_interval(base_time, 4, 0.12, "NORMAL", -4.0, "NORMAL"),
_create_interval(base_time, 5, 0.13, "NORMAL", 0.0, "NORMAL"),
_create_interval(base_time, 6, 0.13, "NORMAL", 1.0, "NORMAL"),
_create_interval(base_time, 7, 0.14, "NORMAL", 3.0, "NORMAL"),
]
config = TibberPricesPeriodConfig(
reverse_sort=False,
flex=0.15,
min_distance_from_avg=0.0,
min_period_length=60,
threshold_low=-10.0,
threshold_high=10.0,
)
time = TibberPricesTimeService(reference_time=base_time + timedelta(hours=1))
existing_period = {
"start": base_time,
"end": base_time + timedelta(minutes=75),
"duration_minutes": 75,
"level": "cheap",
"rating_level": "low",
"rating_difference_%": -9.2,
"price_mean": 0.108,
"price_median": 0.11,
"price_min": 0.10,
"price_max": 0.12,
"price_spread": 0.02,
"price_coefficient_variation_%": 7.7,
"volatility": "low",
"period_interval_count": 5,
"period_price_diff_from_daily_min": 0.008,
"period_price_diff_from_daily_min_%": 8.0,
"period_position": 1,
"period_count_total": 1,
"period_count_remaining": 0,
}
relaxed_period = {
"start": base_time + timedelta(minutes=60),
"end": base_time + timedelta(minutes=120),
"duration_minutes": 60,
"level": "normal",
"rating_level": "normal",
"rating_difference_%": 0.0,
"price_mean": 0.13,
"price_median": 0.13,
"price_min": 0.12,
"price_max": 0.14,
"price_spread": 0.02,
"price_coefficient_variation_%": 5.8,
"volatility": "low",
"period_interval_count": 4,
"period_price_diff_from_daily_min": 0.03,
"period_price_diff_from_daily_min_%": 30.0,
"period_position": 1,
"period_count_total": 1,
"period_count_remaining": 0,
"relaxation_active": True,
"relaxation_level": "flex=18.0% +level_any",
}
merged_periods, periods_added = resolve_period_overlaps(
existing_periods=[existing_period],
new_relaxed_periods=[relaxed_period],
all_prices=all_prices,
config=config,
time=time,
)
assert periods_added == 1
assert len(merged_periods) == 1
merged = merged_periods[0]
expected_prices = [0.10, 0.10, 0.11, 0.11, 0.12, 0.13, 0.13, 0.14]
expected_cv = calculate_coefficient_of_variation(expected_prices)
assert expected_cv is not None
assert merged["start"] == base_time
assert merged["end"] == base_time + timedelta(minutes=120)
assert merged["period_interval_count"] == 8
assert merged["duration_minutes"] == 120
assert merged["price_mean"] == 0.1175
assert merged["price_median"] == 0.115
assert merged["price_min"] == 0.10
assert merged["price_max"] == 0.14
assert merged["price_spread"] == 0.04
assert merged["price_coefficient_variation_%"] == round(expected_cv, 1)
assert merged["level"] == "normal"
assert merged["rating_level"] == "normal"
assert merged["rating_difference_%"] == -5.25
assert merged["period_price_diff_from_daily_min"] == 0.0175
assert merged["period_price_diff_from_daily_min_%"] == 17.5
assert merged["relaxation_active"] is True
assert merged["relaxation_level"] == "flex=18.0% +level_any"
assert "merged_from" in merged

View file

@ -0,0 +1,94 @@
"""Regression tests for period summary day statistics."""
from __future__ import annotations
from datetime import datetime
import pytest
from custom_components.tibber_prices.coordinator.period_handlers.period_statistics import build_period_summary_dict
from custom_components.tibber_prices.coordinator.period_handlers.types import (
TibberPricesPeriodData,
TibberPricesPeriodStatistics,
)
def _build_stats() -> TibberPricesPeriodStatistics:
"""Create minimal summary stats for period-summary tests."""
return TibberPricesPeriodStatistics(
aggregated_level="cheap",
aggregated_rating="low",
rating_difference_pct=-10.0,
price_mean=-0.2,
price_median=-0.2,
price_min=-0.3,
price_max=0.1,
price_spread=0.4,
volatility="moderate",
coefficient_of_variation=12.3,
period_price_diff=0.0,
period_price_diff_pct=0.0,
)
def _build_period_data(day: datetime) -> TibberPricesPeriodData:
"""Create minimal period timing data for summary tests."""
return TibberPricesPeriodData(
start_time=day.replace(hour=1),
end_time=day.replace(hour=2),
period_length=4,
period_idx=1,
total_periods=1,
)
@pytest.mark.unit
class TestPeriodSummaryDayVolatility:
"""Validate day_volatility_% semantics on extreme price days."""
def test_day_volatility_uses_absolute_average_for_negative_price_days(self) -> None:
"""Negative-average days should still report meaningful volatility percentage."""
day = datetime(2025, 11, 22)
summary = build_period_summary_dict(
_build_period_data(day),
_build_stats(),
reverse_sort=False,
price_context={
"intervals_by_day": {
day.date(): [
{"total": -0.30},
{"total": -0.10},
{"total": 0.10},
]
},
"avg_prices": {day.date(): -0.10},
},
)
assert summary["day_volatility_%"] == 400.0
assert summary["day_price_min"] == -30.0
assert summary["day_price_max"] == 10.0
assert summary["day_price_span"] == 40.0
def test_day_volatility_is_none_when_day_average_is_zero(self) -> None:
"""Zero-average days should avoid reporting a misleading 0% volatility."""
day = datetime(2025, 11, 23)
summary = build_period_summary_dict(
_build_period_data(day),
_build_stats(),
reverse_sort=False,
price_context={
"intervals_by_day": {
day.date(): [
{"total": -0.20},
{"total": 0.20},
]
},
"avg_prices": {day.date(): 0.0},
},
)
assert summary["day_volatility_%"] is None
assert summary["day_price_min"] == -20.0
assert summary["day_price_max"] == 20.0
assert summary["day_price_span"] == 40.0

233
tests/test_periods_hash.py Normal file
View file

@ -0,0 +1,233 @@
"""Regression tests for period calculation cache hashing."""
from __future__ import annotations
from copy import deepcopy
from typing import Any
from unittest.mock import Mock
import pytest
from custom_components.tibber_prices import const as _const
from custom_components.tibber_prices.coordinator import periods as periods_module
from custom_components.tibber_prices.coordinator.periods import TibberPricesPeriodCalculator
from custom_components.tibber_prices.coordinator.time_service import TibberPricesTimeService
from homeassistant.util import dt as dt_util
def _create_hash_interval(starts_at: str, price: float, level: str, rating_level: str, difference: float) -> dict:
"""Create one interval for period hash tests."""
parsed = dt_util.parse_datetime(starts_at)
assert parsed is not None
return {
"startsAt": parsed,
"total": price,
"level": level,
"rating_level": rating_level,
"difference": difference,
}
def _create_hash_price_info() -> list[dict]:
"""Create minimal today/tomorrow data for cache hash tests."""
return [
_create_hash_interval("2025-11-22T00:00:00+01:00", 0.11, "CHEAP", "LOW", -12.0),
_create_hash_interval("2025-11-22T00:15:00+01:00", 0.12, "NORMAL", "NORMAL", -4.0),
_create_hash_interval("2025-11-23T00:00:00+01:00", 0.13, "NORMAL", "NORMAL", 0.0),
_create_hash_interval("2025-11-23T00:15:00+01:00", 0.14, "EXPENSIVE", "HIGH", 12.0),
]
def _compute_hash(calculator: TibberPricesPeriodCalculator, price_info: list[dict]) -> str:
"""Call the internal periods hash helper without tripping the private-access lint rule."""
return calculator._compute_periods_hash(price_info) # noqa: SLF001 - targeted cache-key regression check
def _create_calculator(options: dict[str, Any]) -> TibberPricesPeriodCalculator:
"""Create a period calculator with deterministic test time."""
calculator = TibberPricesPeriodCalculator(Mock(options=options), "[test]")
reference_time = dt_util.parse_datetime("2025-11-22T12:00:00+01:00")
assert reference_time is not None
calculator.time = TibberPricesTimeService(reference_time=reference_time)
return calculator
@pytest.mark.unit
@pytest.mark.freeze_time("2025-11-22 12:00:00+01:00")
class TestPeriodsHash:
"""Validate that same-day value changes invalidate the period cache."""
def test_hash_changes_when_same_day_price_changes(self) -> None:
"""Changing an interval price with identical timestamps must change the cache hash."""
calculator = TibberPricesPeriodCalculator(Mock(options={}), "[test]")
original = _create_hash_price_info()
updated = deepcopy(original)
updated[1]["total"] = 0.125
assert _compute_hash(calculator, original) != _compute_hash(calculator, updated)
def test_hash_changes_when_same_day_level_changes(self) -> None:
"""Changing level/rating metadata with identical timestamps must change the cache hash."""
calculator = TibberPricesPeriodCalculator(Mock(options={}), "[test]")
original = _create_hash_price_info()
updated = deepcopy(original)
updated[1]["level"] = "CHEAP"
updated[1]["rating_level"] = "LOW"
updated[1]["difference"] = -10.0
assert _compute_hash(calculator, original) != _compute_hash(calculator, updated)
@pytest.mark.unit
@pytest.mark.freeze_time("2025-11-22 12:00:00+01:00")
class TestPeriodConfigNormalization:
"""Validate numeric period config values degrade cleanly to defaults."""
def test_get_period_config_falls_back_for_invalid_numeric_options(self) -> None:
"""Malformed config values should fall back to defaults instead of raising."""
calculator = _create_calculator(
{
"flexibility_settings": {
_const.CONF_BEST_PRICE_FLEX: "bad-flex",
_const.CONF_BEST_PRICE_MIN_DISTANCE_FROM_AVG: "bad-distance",
},
"period_settings": {
_const.CONF_BEST_PRICE_MIN_PERIOD_LENGTH: "bad-min-length",
},
"extension_settings": {
_const.CONF_BEST_PRICE_MAX_EXTENSION_INTERVALS: "bad-extension",
_const.CONF_BEST_PRICE_GEOMETRIC_FLEX: "bad-geometric",
_const.CONF_BEST_PRICE_SEGMENT_MIN_PERIODS: "bad-segments",
},
}
)
config = calculator.get_period_config(reverse_sort=False)
assert config["flex"] == abs(_const.DEFAULT_BEST_PRICE_FLEX) / 100
assert config["min_distance_from_avg"] == abs(_const.DEFAULT_BEST_PRICE_MIN_DISTANCE_FROM_AVG)
assert config["min_period_length"] == _const.DEFAULT_BEST_PRICE_MIN_PERIOD_LENGTH
assert config["max_extension_intervals"] == _const.DEFAULT_BEST_PRICE_MAX_EXTENSION_INTERVALS
assert config["geometric_extra_flex"] == _const.DEFAULT_BEST_PRICE_GEOMETRIC_FLEX / 100
assert config["segment_min_periods"] == _const.DEFAULT_BEST_PRICE_SEGMENT_MIN_PERIODS
def test_should_show_periods_falls_back_for_invalid_gap_count(self) -> None:
"""Malformed gap_count should not break day-level filter checks."""
calculator = _create_calculator(
{
"period_settings": {
_const.CONF_BEST_PRICE_MAX_LEVEL: "cheap",
_const.CONF_BEST_PRICE_MAX_LEVEL_GAP_COUNT: "bad-gap-count",
}
}
)
assert (
calculator.should_show_periods(
[
_create_hash_interval(
"2025-11-22T00:00:00+01:00", 0.11, level="CHEAP", rating_level="LOW", difference=-12.0
),
_create_hash_interval(
"2025-11-22T00:15:00+01:00", 0.10, level="CHEAP", rating_level="LOW", difference=-14.0
),
_create_hash_interval(
"2025-11-22T00:30:00+01:00", 0.09, level="CHEAP", rating_level="LOW", difference=-16.0
),
_create_hash_interval(
"2025-11-22T00:45:00+01:00", 0.08, level="CHEAP", rating_level="LOW", difference=-18.0
),
],
reverse_sort=False,
)
is True
)
def test_calculate_periods_for_price_info_falls_back_for_invalid_runtime_numbers(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Runtime period calculation should use defaults when numeric overrides are malformed."""
captured_calls: list[dict[str, Any]] = []
def _fake_calculate_periods_with_relaxation(
all_prices: list[dict[str, Any]],
*,
config: Any,
enable_relaxation: bool,
min_periods: int,
max_relaxation_attempts: int,
should_show_callback: Any,
time: Any,
config_entry: Any,
day_patterns_by_date: Any,
) -> dict[str, Any]:
captured_calls.append(
{
"reverse_sort": config.reverse_sort,
"min_periods": min_periods,
"max_relaxation_attempts": max_relaxation_attempts,
"gap_count": config.gap_count,
"threshold_low": config.threshold_low,
"threshold_high": config.threshold_high,
"threshold_volatility_moderate": config.threshold_volatility_moderate,
"threshold_volatility_high": config.threshold_volatility_high,
"threshold_volatility_very_high": config.threshold_volatility_very_high,
}
)
return {
"periods": [],
"intervals": [],
"metadata": {
"total_intervals": len(all_prices),
"total_periods": 0,
"config": {},
"relaxation": {"relaxation_active": enable_relaxation, "relaxation_attempted": enable_relaxation},
},
}
monkeypatch.setattr(
periods_module, "calculate_periods_with_relaxation", _fake_calculate_periods_with_relaxation
)
calculator = _create_calculator(
{
_const.CONF_PRICE_RATING_THRESHOLD_LOW: "bad-threshold-low",
_const.CONF_PRICE_RATING_THRESHOLD_HIGH: "bad-threshold-high",
_const.CONF_VOLATILITY_THRESHOLD_MODERATE: "bad-vol-moderate",
_const.CONF_VOLATILITY_THRESHOLD_HIGH: "bad-vol-high",
_const.CONF_VOLATILITY_THRESHOLD_VERY_HIGH: "bad-vol-very-high",
"relaxation_and_target_periods": {
_const.CONF_ENABLE_MIN_PERIODS_BEST: True,
_const.CONF_ENABLE_MIN_PERIODS_PEAK: True,
_const.CONF_MIN_PERIODS_BEST: "bad-min-best",
_const.CONF_RELAXATION_ATTEMPTS_BEST: "bad-attempts-best",
_const.CONF_MIN_PERIODS_PEAK: "bad-min-peak",
_const.CONF_RELAXATION_ATTEMPTS_PEAK: "bad-attempts-peak",
},
"period_settings": {
_const.CONF_BEST_PRICE_MAX_LEVEL_GAP_COUNT: "bad-best-gap",
_const.CONF_PEAK_PRICE_MAX_LEVEL_GAP_COUNT: "bad-peak-gap",
},
}
)
result = calculator.calculate_periods_for_price_info(_create_hash_price_info())
assert result["best_price"]["metadata"]["total_periods"] == 0
assert result["peak_price"]["metadata"]["total_periods"] == 0
assert len(captured_calls) == 2
best_call = next(call for call in captured_calls if call["reverse_sort"] is False)
peak_call = next(call for call in captured_calls if call["reverse_sort"] is True)
assert best_call["min_periods"] == _const.DEFAULT_MIN_PERIODS_BEST
assert best_call["max_relaxation_attempts"] == _const.DEFAULT_RELAXATION_ATTEMPTS_BEST
assert best_call["gap_count"] == _const.DEFAULT_BEST_PRICE_MAX_LEVEL_GAP_COUNT
assert peak_call["min_periods"] == _const.DEFAULT_MIN_PERIODS_PEAK
assert peak_call["max_relaxation_attempts"] == _const.DEFAULT_RELAXATION_ATTEMPTS_PEAK
assert peak_call["gap_count"] == _const.DEFAULT_PEAK_PRICE_MAX_LEVEL_GAP_COUNT
assert best_call["threshold_low"] == _const.DEFAULT_PRICE_RATING_THRESHOLD_LOW
assert best_call["threshold_high"] == _const.DEFAULT_PRICE_RATING_THRESHOLD_HIGH
assert best_call["threshold_volatility_moderate"] == _const.DEFAULT_VOLATILITY_THRESHOLD_MODERATE
assert best_call["threshold_volatility_high"] == _const.DEFAULT_VOLATILITY_THRESHOLD_HIGH
assert best_call["threshold_volatility_very_high"] == _const.DEFAULT_VOLATILITY_THRESHOLD_VERY_HIGH