mirror of
https://github.com/jpawlowski/hass.tibber_prices.git
synced 2026-05-28 18:43:40 +00:00
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:
parent
10c83d6720
commit
bbcfdd4443
12 changed files with 1112 additions and 123 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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."""
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
_const.CONF_PEAK_PRICE_MIN_PERIOD_LENGTH,
|
self._get_option(
|
||||||
|
_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(
|
||||||
_const.CONF_BEST_PRICE_MIN_PERIOD_LENGTH,
|
self._get_option(
|
||||||
|
_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(
|
||||||
_const.CONF_PEAK_PRICE_MAX_LEVEL_GAP_COUNT,
|
self._get_option(
|
||||||
|
_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(
|
||||||
_const.CONF_BEST_PRICE_MAX_LEVEL_GAP_COUNT,
|
self._get_option(
|
||||||
|
_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(
|
||||||
_const.CONF_PRICE_RATING_THRESHOLD_LOW,
|
self.config_entry.options.get(
|
||||||
|
_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(
|
||||||
_const.CONF_PRICE_RATING_THRESHOLD_HIGH,
|
self.config_entry.options.get(
|
||||||
|
_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(
|
||||||
_const.CONF_VOLATILITY_THRESHOLD_MODERATE,
|
self.config_entry.options.get(
|
||||||
|
_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(
|
||||||
_const.CONF_VOLATILITY_THRESHOLD_HIGH,
|
self.config_entry.options.get(
|
||||||
|
_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(
|
||||||
_const.CONF_VOLATILITY_THRESHOLD_VERY_HIGH,
|
self.config_entry.options.get(
|
||||||
|
_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(
|
||||||
_const.CONF_MIN_PERIODS_BEST,
|
self._get_option(
|
||||||
"relaxation_and_target_periods",
|
_const.CONF_MIN_PERIODS_BEST,
|
||||||
|
"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(
|
||||||
_const.CONF_RELAXATION_ATTEMPTS_BEST,
|
self._get_option(
|
||||||
"relaxation_and_target_periods",
|
_const.CONF_RELAXATION_ATTEMPTS_BEST,
|
||||||
|
"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(
|
||||||
_const.CONF_BEST_PRICE_MAX_LEVEL_GAP_COUNT,
|
self._get_option(
|
||||||
"period_settings",
|
_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,
|
||||||
)
|
)
|
||||||
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(
|
||||||
_const.CONF_MIN_PERIODS_PEAK,
|
self._get_option(
|
||||||
"relaxation_and_target_periods",
|
_const.CONF_MIN_PERIODS_PEAK,
|
||||||
|
"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(
|
||||||
_const.CONF_RELAXATION_ATTEMPTS_PEAK,
|
self._get_option(
|
||||||
"relaxation_and_target_periods",
|
_const.CONF_RELAXATION_ATTEMPTS_PEAK,
|
||||||
|
"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(
|
||||||
_const.CONF_PEAK_PRICE_MAX_LEVEL_GAP_COUNT,
|
self._get_option(
|
||||||
"period_settings",
|
_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,
|
||||||
)
|
)
|
||||||
peak_period_config = TibberPricesPeriodConfig(
|
peak_period_config = TibberPricesPeriodConfig(
|
||||||
reverse_sort=True,
|
reverse_sort=True,
|
||||||
|
|
|
||||||
71
tests/test_binary_sensor_period_attributes.py
Normal file
71
tests/test_binary_sensor_period_attributes.py
Normal 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
|
||||||
134
tests/test_period_overlap.py
Normal file
134
tests/test_period_overlap.py
Normal 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
|
||||||
94
tests/test_period_statistics.py
Normal file
94
tests/test_period_statistics.py
Normal 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
233
tests/test_periods_hash.py
Normal 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
|
||||||
Loading…
Reference in a new issue