mirror of
https://github.com/jpawlowski/hass.tibber_prices.git
synced 2026-05-28 18:43:40 +00:00
refactor(periods): enhance peak period filtering and validation logic
Some checks failed
Deploy Docusaurus Documentation (Dual Sites) / Build and Deploy Documentation Sites (push) Waiting to run
Lint / Ruff (push) Waiting to run
Validate / Hassfest validation (push) Waiting to run
Validate / HACS validation (push) Waiting to run
Auto-Tag on Version Bump / Check and create version tag (push) Has been cancelled
Some checks failed
Deploy Docusaurus Documentation (Dual Sites) / Build and Deploy Documentation Sites (push) Waiting to run
Lint / Ruff (push) Waiting to run
Validate / Hassfest validation (push) Waiting to run
Validate / HACS validation (push) Waiting to run
Auto-Tag on Version Bump / Check and create version tag (push) Has been cancelled
Improve the filtering of peak periods to eliminate cross-day artifacts and ensure that only genuine high-price windows are retained. This includes adjustments to the criteria for peak classification and the introduction of validation against previous day's prices for overnight intervals. Impact: Users will experience more accurate peak pricing data, reducing misleading peak classifications on flat days.
This commit is contained in:
parent
4f2bea6720
commit
2b63440933
6 changed files with 275 additions and 52 deletions
|
|
@ -20,6 +20,7 @@ from .period_building import (
|
||||||
filter_periods_by_end_date,
|
filter_periods_by_end_date,
|
||||||
filter_periods_by_min_length,
|
filter_periods_by_min_length,
|
||||||
filter_superseded_periods,
|
filter_superseded_periods,
|
||||||
|
filter_weak_peak_periods,
|
||||||
split_intervals_by_day,
|
split_intervals_by_day,
|
||||||
)
|
)
|
||||||
from .period_statistics import extract_period_summaries
|
from .period_statistics import extract_period_summaries
|
||||||
|
|
@ -270,6 +271,16 @@ def calculate_periods(
|
||||||
reverse_sort=reverse_sort,
|
reverse_sort=reverse_sort,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Step 10: Filter weak peak periods
|
||||||
|
# Peak periods whose mean price is barely above daily average are likely
|
||||||
|
# cross-day artifacts rather than genuine high-price windows
|
||||||
|
if reverse_sort:
|
||||||
|
period_summaries = filter_weak_peak_periods(
|
||||||
|
period_summaries,
|
||||||
|
avg_price_by_day,
|
||||||
|
time=time,
|
||||||
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"periods": period_summaries, # Lightweight summaries only
|
"periods": period_summaries, # Lightweight summaries only
|
||||||
"metadata": {
|
"metadata": {
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ if TYPE_CHECKING:
|
||||||
from custom_components.tibber_prices.coordinator.time_service import TibberPricesTimeService
|
from custom_components.tibber_prices.coordinator.time_service import TibberPricesTimeService
|
||||||
|
|
||||||
from .level_filtering import apply_level_filter, check_interval_criteria, compute_geometric_flex_bonus
|
from .level_filtering import apply_level_filter, check_interval_criteria, compute_geometric_flex_bonus
|
||||||
from .types import TibberPricesIntervalCriteria
|
from .types import CROSS_DAY_OVERNIGHT_VALIDATION_HOUR, TibberPricesIntervalCriteria
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
_LOGGER_DETAILS = logging.getLogger(__name__ + ".details")
|
_LOGGER_DETAILS = logging.getLogger(__name__ + ".details")
|
||||||
|
|
@ -171,6 +171,23 @@ def build_periods(
|
||||||
effective_criteria = criteria._replace(flex=criteria.flex + geo_bonus) if geo_bonus > 0 else criteria
|
effective_criteria = criteria._replace(flex=criteria.flex + geo_bonus) if geo_bonus > 0 else criteria
|
||||||
in_flex, meets_min_distance = check_interval_criteria(price_for_criteria, effective_criteria)
|
in_flex, meets_min_distance = check_interval_criteria(price_for_criteria, effective_criteria)
|
||||||
|
|
||||||
|
# Cross-day boundary validation for peak periods:
|
||||||
|
# Overnight intervals (00:00-05:59) must ALSO qualify against the previous
|
||||||
|
# day's reference price. Without this, prices like 30ct become "peak" against
|
||||||
|
# tomorrow's lower max (35ct) but weren't peak against today's higher max (39ct).
|
||||||
|
if reverse_sort and in_flex and starts_at.hour < CROSS_DAY_OVERNIGHT_VALIDATION_HOUR:
|
||||||
|
prev_day = date_key - timedelta(days=1)
|
||||||
|
prev_criteria = criteria_by_day.get(prev_day)
|
||||||
|
if prev_criteria is not None:
|
||||||
|
prev_effective = (
|
||||||
|
prev_criteria._replace(flex=prev_criteria.flex + geo_bonus) if geo_bonus > 0 else prev_criteria
|
||||||
|
)
|
||||||
|
in_prev_flex, _ = check_interval_criteria(price_for_criteria, prev_effective)
|
||||||
|
if not in_prev_flex:
|
||||||
|
# Fails against previous day → boundary artifact, treat as not in flex
|
||||||
|
in_flex = False
|
||||||
|
intervals_filtered_by_flex += 1
|
||||||
|
|
||||||
# Track why intervals are filtered
|
# Track why intervals are filtered
|
||||||
if not in_flex:
|
if not in_flex:
|
||||||
intervals_filtered_by_flex += 1
|
intervals_filtered_by_flex += 1
|
||||||
|
|
@ -391,6 +408,86 @@ def _filter_superseded_today_periods(
|
||||||
return kept
|
return kept
|
||||||
|
|
||||||
|
|
||||||
|
def _filter_best_superseded_periods(
|
||||||
|
today_late: list[dict],
|
||||||
|
tomorrow_early: list[dict],
|
||||||
|
other: list[dict],
|
||||||
|
improvement_threshold: float,
|
||||||
|
) -> list[dict]:
|
||||||
|
"""Filter best-price today-late periods superseded by cheaper tomorrow alternatives."""
|
||||||
|
if not tomorrow_early:
|
||||||
|
return other + today_late + tomorrow_early
|
||||||
|
|
||||||
|
# Find the cheapest tomorrow early period
|
||||||
|
best_tomorrow = min(tomorrow_early, key=lambda p: p.get("price_mean", float("inf")))
|
||||||
|
best_tomorrow_price = best_tomorrow.get("price_mean")
|
||||||
|
|
||||||
|
if best_tomorrow_price is None:
|
||||||
|
return other + today_late + tomorrow_early
|
||||||
|
|
||||||
|
kept_today = _filter_superseded_today_periods(
|
||||||
|
today_late,
|
||||||
|
best_tomorrow,
|
||||||
|
best_tomorrow_price,
|
||||||
|
improvement_threshold,
|
||||||
|
)
|
||||||
|
|
||||||
|
return other + kept_today + tomorrow_early
|
||||||
|
|
||||||
|
|
||||||
|
def _filter_peak_superseded_periods(
|
||||||
|
today_late: list[dict],
|
||||||
|
tomorrow_early: list[dict],
|
||||||
|
other: list[dict],
|
||||||
|
improvement_threshold: float,
|
||||||
|
) -> list[dict]:
|
||||||
|
"""
|
||||||
|
Filter peak-price tomorrow-early periods that are artifacts of day-boundary reclassification.
|
||||||
|
|
||||||
|
If today has a genuine late-night peak and tomorrow's early-morning "peak" is
|
||||||
|
significantly LOWER in price, the tomorrow period is a cross-day artifact:
|
||||||
|
the same overnight prices are classified differently because they sit near
|
||||||
|
a different day's maximum.
|
||||||
|
|
||||||
|
"""
|
||||||
|
if not today_late or not tomorrow_early:
|
||||||
|
return other + today_late + tomorrow_early
|
||||||
|
|
||||||
|
# Find the strongest today late peak (highest mean price)
|
||||||
|
best_today_peak = max(today_late, key=lambda p: p.get("price_mean", 0))
|
||||||
|
best_today_price = best_today_peak.get("price_mean")
|
||||||
|
|
||||||
|
if best_today_price is None or best_today_price <= 0:
|
||||||
|
return other + today_late + tomorrow_early
|
||||||
|
|
||||||
|
kept_tomorrow: list[dict] = []
|
||||||
|
for tomorrow_period in tomorrow_early:
|
||||||
|
tomorrow_price = tomorrow_period.get("price_mean")
|
||||||
|
|
||||||
|
if tomorrow_price is None:
|
||||||
|
kept_tomorrow.append(tomorrow_period)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# How much LOWER is tomorrow's peak vs today's peak? (as percentage)
|
||||||
|
price_drop_pct = ((best_today_price - tomorrow_price) / best_today_price * 100) if best_today_price > 0 else 0
|
||||||
|
|
||||||
|
if price_drop_pct >= improvement_threshold:
|
||||||
|
_LOGGER.info(
|
||||||
|
"Peak supersession: Tomorrow %s-%s (%.2f) is %.1f%% below today's peak %s-%s (%.2f) → filtered as artifact",
|
||||||
|
tomorrow_period["start"].strftime("%H:%M"),
|
||||||
|
tomorrow_period["end"].strftime("%H:%M"),
|
||||||
|
tomorrow_price,
|
||||||
|
price_drop_pct,
|
||||||
|
best_today_peak["start"].strftime("%H:%M"),
|
||||||
|
best_today_peak["end"].strftime("%H:%M"),
|
||||||
|
best_today_price,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
kept_tomorrow.append(tomorrow_period)
|
||||||
|
|
||||||
|
return other + today_late + kept_tomorrow
|
||||||
|
|
||||||
|
|
||||||
def filter_superseded_periods(
|
def filter_superseded_periods(
|
||||||
period_summaries: list[dict],
|
period_summaries: list[dict],
|
||||||
*,
|
*,
|
||||||
|
|
@ -398,19 +495,18 @@ def filter_superseded_periods(
|
||||||
reverse_sort: bool,
|
reverse_sort: bool,
|
||||||
) -> list[dict]:
|
) -> list[dict]:
|
||||||
"""
|
"""
|
||||||
Filter out late-night today periods that are superseded by better tomorrow periods.
|
Filter out cross-day periods that are artifacts of day-boundary price reclassification.
|
||||||
|
|
||||||
|
For BEST PRICE (reverse_sort=False):
|
||||||
When tomorrow's data becomes available, some late-night periods that were found
|
When tomorrow's data becomes available, some late-night periods that were found
|
||||||
through relaxation may no longer make sense. If tomorrow has a significantly
|
through relaxation may no longer make sense. If tomorrow has a significantly
|
||||||
better period in the early morning, the late-night today period is obsolete.
|
better (cheaper) period in the early morning, the late-night today period is obsolete.
|
||||||
|
|
||||||
Example:
|
For PEAK PRICE (reverse_sort=True):
|
||||||
- Today 23:30-00:00 at 0.70 kr (found via relaxation, was best available)
|
Inverted logic: tomorrow's early-morning periods that are significantly LOWER
|
||||||
- Tomorrow 04:00-05:30 at 0.50 kr (much better alternative)
|
than today's late-night peak are cross-day artifacts. Overnight prices often
|
||||||
→ The today period is superseded and should be filtered out
|
qualify as "peak" against tomorrow's (lower) daily max, but don't represent
|
||||||
|
genuine high-price windows when viewed across the day boundary.
|
||||||
This only applies to best-price periods (reverse_sort=False).
|
|
||||||
Peak-price periods are not filtered this way.
|
|
||||||
|
|
||||||
"""
|
"""
|
||||||
from .types import ( # noqa: PLC0415
|
from .types import ( # noqa: PLC0415
|
||||||
|
|
@ -425,8 +521,7 @@ def filter_superseded_periods(
|
||||||
reverse_sort,
|
reverse_sort,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Only filter for best-price periods
|
if not period_summaries:
|
||||||
if reverse_sort or not period_summaries:
|
|
||||||
return period_summaries
|
return period_summaries
|
||||||
|
|
||||||
now = time.now()
|
now = time.now()
|
||||||
|
|
@ -449,33 +544,139 @@ def filter_superseded_periods(
|
||||||
len(other),
|
len(other),
|
||||||
)
|
)
|
||||||
|
|
||||||
# If no tomorrow early periods, nothing to compare against
|
if reverse_sort:
|
||||||
if not tomorrow_early:
|
# PEAK: Filter tomorrow-early periods superseded by today-late peaks
|
||||||
_LOGGER.debug("No tomorrow early periods - skipping supersession check")
|
result = _filter_peak_superseded_periods(
|
||||||
return period_summaries
|
today_late,
|
||||||
|
tomorrow_early,
|
||||||
|
other,
|
||||||
|
SUPERSESSION_PRICE_IMPROVEMENT_PCT,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# BEST: Filter today-late periods superseded by cheaper tomorrow alternatives
|
||||||
|
result = _filter_best_superseded_periods(
|
||||||
|
today_late,
|
||||||
|
tomorrow_early,
|
||||||
|
other,
|
||||||
|
SUPERSESSION_PRICE_IMPROVEMENT_PCT,
|
||||||
|
)
|
||||||
|
|
||||||
# Find the best tomorrow early period (lowest mean price)
|
|
||||||
best_tomorrow = min(tomorrow_early, key=lambda p: p.get("price_mean", float("inf")))
|
|
||||||
best_tomorrow_price = best_tomorrow.get("price_mean")
|
|
||||||
|
|
||||||
if best_tomorrow_price is None:
|
|
||||||
return period_summaries
|
|
||||||
|
|
||||||
# Filter superseded today periods
|
|
||||||
kept_today = _filter_superseded_today_periods(
|
|
||||||
today_late,
|
|
||||||
best_tomorrow,
|
|
||||||
best_tomorrow_price,
|
|
||||||
SUPERSESSION_PRICE_IMPROVEMENT_PCT,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Reconstruct and sort by start time
|
|
||||||
result = other + kept_today + tomorrow_early
|
|
||||||
result.sort(key=lambda p: p.get("start") or time.now())
|
result.sort(key=lambda p: p.get("start") or time.now())
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def filter_weak_peak_periods(
|
||||||
|
period_summaries: list[dict],
|
||||||
|
avg_prices: dict,
|
||||||
|
*,
|
||||||
|
time: TibberPricesTimeService,
|
||||||
|
) -> list[dict]:
|
||||||
|
"""
|
||||||
|
Filter peak periods whose mean price is barely above the daily average.
|
||||||
|
|
||||||
|
A genuine peak period should have prices meaningfully above the daily average.
|
||||||
|
Periods that are only marginally above average are typically cross-day artifacts
|
||||||
|
where overnight prices qualify as "peak" against a low daily maximum.
|
||||||
|
|
||||||
|
Safety: At least one period per day is always preserved (the one with the
|
||||||
|
highest premium above average). This prevents removing all peaks on flat days.
|
||||||
|
|
||||||
|
Only applies to peak periods. Best-price filtering is not needed because
|
||||||
|
cheap periods near the daily average are still useful for scheduling.
|
||||||
|
|
||||||
|
"""
|
||||||
|
from .types import CROSS_DAY_OVERNIGHT_VALIDATION_HOUR, PEAK_MIN_PREMIUM_ABOVE_AVG_PCT # noqa: PLC0415
|
||||||
|
|
||||||
|
if not period_summaries:
|
||||||
|
return period_summaries
|
||||||
|
|
||||||
|
# Calculate premium for each period and group by day
|
||||||
|
period_premiums: list[tuple[dict, float, date]] = []
|
||||||
|
for period in period_summaries:
|
||||||
|
period_mean = period.get("price_mean")
|
||||||
|
period_start = period.get("start")
|
||||||
|
|
||||||
|
if period_mean is None or period_start is None:
|
||||||
|
period_premiums.append((period, float("inf"), date.min))
|
||||||
|
continue
|
||||||
|
|
||||||
|
day_key = period_start.date()
|
||||||
|
daily_avg = avg_prices.get(day_key) or avg_prices.get(str(day_key))
|
||||||
|
|
||||||
|
if daily_avg is None or daily_avg <= 0:
|
||||||
|
period_premiums.append((period, float("inf"), day_key))
|
||||||
|
continue
|
||||||
|
|
||||||
|
# For overnight/morning periods (before 06:00), use the HIGHER of
|
||||||
|
# current day and previous day averages. This prevents overnight prices
|
||||||
|
# from appearing as "peaks" when tomorrow's average is lower due to
|
||||||
|
# midday valleys (e.g., solar surplus). A genuine peak must be high
|
||||||
|
# relative to BOTH days' price landscape.
|
||||||
|
effective_avg = daily_avg
|
||||||
|
if period_start.hour < CROSS_DAY_OVERNIGHT_VALIDATION_HOUR:
|
||||||
|
prev_day = day_key - timedelta(days=1)
|
||||||
|
prev_avg = avg_prices.get(prev_day) or avg_prices.get(str(prev_day))
|
||||||
|
if prev_avg is not None and prev_avg > daily_avg:
|
||||||
|
effective_avg = prev_avg
|
||||||
|
_LOGGER_DETAILS.debug(
|
||||||
|
"%sWeak peak check: Period %s uses prev-day avg %.4f instead of %.4f (overnight cross-day)",
|
||||||
|
INDENT_L0,
|
||||||
|
period_start.strftime("%H:%M"),
|
||||||
|
prev_avg,
|
||||||
|
daily_avg,
|
||||||
|
)
|
||||||
|
|
||||||
|
premium_pct = ((period_mean - effective_avg) / effective_avg) * 100
|
||||||
|
period_premiums.append((period, premium_pct, day_key))
|
||||||
|
|
||||||
|
# Find the best (highest premium) period per day
|
||||||
|
best_per_day: dict[date, float] = {}
|
||||||
|
for _period, premium, day in period_premiums:
|
||||||
|
if day not in best_per_day or premium > best_per_day[day]:
|
||||||
|
best_per_day[day] = premium
|
||||||
|
|
||||||
|
# Filter: keep periods that pass threshold OR are the best for their day
|
||||||
|
kept: list[dict] = []
|
||||||
|
removed = 0
|
||||||
|
for period, premium, day in period_premiums:
|
||||||
|
is_best_for_day = premium >= best_per_day.get(day, float("-inf"))
|
||||||
|
|
||||||
|
if premium >= PEAK_MIN_PREMIUM_ABOVE_AVG_PCT:
|
||||||
|
kept.append(period)
|
||||||
|
elif is_best_for_day:
|
||||||
|
# Preserve at least one period per day even if below threshold
|
||||||
|
kept.append(period)
|
||||||
|
_LOGGER_DETAILS.debug(
|
||||||
|
"%sWeak peak preserved (best for day %s): premium=%.1f%% < threshold=%.1f%%",
|
||||||
|
INDENT_L0,
|
||||||
|
day,
|
||||||
|
premium,
|
||||||
|
PEAK_MIN_PREMIUM_ABOVE_AVG_PCT,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
period_start = period.get("start")
|
||||||
|
_LOGGER.info(
|
||||||
|
"Weak peak filtered: Period %s-%s mean=%.2f is only %.1f%% above daily avg (need ≥%.1f%%)",
|
||||||
|
period_start.strftime("%H:%M") if period_start else "?",
|
||||||
|
period["end"].strftime("%H:%M") if period.get("end") else "?",
|
||||||
|
period.get("price_mean", 0),
|
||||||
|
premium,
|
||||||
|
PEAK_MIN_PREMIUM_ABOVE_AVG_PCT,
|
||||||
|
)
|
||||||
|
removed += 1
|
||||||
|
|
||||||
|
if removed > 0:
|
||||||
|
_LOGGER.info(
|
||||||
|
"Weak peak filter: %d/%d periods kept (removed %d below %.0f%% premium threshold)",
|
||||||
|
len(kept),
|
||||||
|
len(period_summaries),
|
||||||
|
removed,
|
||||||
|
PEAK_MIN_PREMIUM_ABOVE_AVG_PCT,
|
||||||
|
)
|
||||||
|
|
||||||
|
return kept
|
||||||
|
|
||||||
|
|
||||||
def _is_period_eligible_for_extension(
|
def _is_period_eligible_for_extension(
|
||||||
period: dict,
|
period: dict,
|
||||||
today: date,
|
today: date,
|
||||||
|
|
|
||||||
|
|
@ -462,17 +462,16 @@ def _compute_day_effective_min(
|
||||||
Uses IQR% as primary metric (robust to isolated price spikes) with CV as
|
Uses IQR% as primary metric (robust to isolated price spikes) with CV as
|
||||||
fallback when IQR% is undefined (near-zero or negative median prices).
|
fallback when IQR% is undefined (near-zero or negative median prices).
|
||||||
|
|
||||||
This applies ONLY to BEST PRICE periods (reverse_sort=False). For PEAK PRICE
|
This applies to both BEST PRICE and PEAK PRICE periods. On flat days,
|
||||||
periods, full relaxation should run even on flat days because identifying the
|
forcing 2+ peaks via relaxation creates cross-day boundary artifacts
|
||||||
genuinely most expensive window requires the complete filter evaluation.
|
where overnight prices barely qualify as "peak" only because they are
|
||||||
(Design decision: if the user explicitly disabled relaxation, honour the
|
the second-highest block relative to that day's maximum.
|
||||||
configured min_periods exactly regardless.)
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
prices_by_day: Dict of date → list of price dicts
|
prices_by_day: Dict of date → list of price dicts
|
||||||
min_periods: Configured minimum periods per day
|
min_periods: Configured minimum periods per day
|
||||||
enable_relaxation: Whether relaxation is enabled
|
enable_relaxation: Whether relaxation is enabled
|
||||||
reverse_sort: True for peak price (no adaptation), False for best price
|
reverse_sort: True for peak price, False for best price
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Tuple of (dict of date → effective min_periods for that day, count of flat days detected)
|
Tuple of (dict of date → effective min_periods for that day, count of flat days detected)
|
||||||
|
|
@ -482,8 +481,8 @@ def _compute_day_effective_min(
|
||||||
flat_day_count = 0
|
flat_day_count = 0
|
||||||
|
|
||||||
for day, day_prices in prices_by_day.items():
|
for day, day_prices in prices_by_day.items():
|
||||||
if not enable_relaxation or min_periods <= 1 or reverse_sort:
|
if not enable_relaxation or min_periods <= 1:
|
||||||
# Relaxation disabled, already 1, or peak price: no adaptation
|
# Relaxation disabled or already 1: no adaptation
|
||||||
day_effective_min[day] = min_periods
|
day_effective_min[day] = min_periods
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,19 @@ CROSS_DAY_MAX_PRICE_DEVIATION = 0.15 # Stop if price deviates >15% from origina
|
||||||
# A today period is "superseded" if tomorrow has a significantly better alternative
|
# A today period is "superseded" if tomorrow has a significantly better alternative
|
||||||
SUPERSESSION_PRICE_IMPROVEMENT_PCT = 10.0 # Tomorrow must be at least 10% cheaper to supersede
|
SUPERSESSION_PRICE_IMPROVEMENT_PCT = 10.0 # Tomorrow must be at least 10% cheaper to supersede
|
||||||
|
|
||||||
|
# Peak Price Quality: Minimum premium above daily average to qualify as genuine peak
|
||||||
|
# A peak period whose mean price is barely above the daily average is likely a
|
||||||
|
# cross-day artifact rather than a genuine high-price window.
|
||||||
|
# Example: daily_avg=28ct, premium=10% → peak must average ≥ 30.8ct
|
||||||
|
PEAK_MIN_PREMIUM_ABOVE_AVG_PCT = 10.0 # Peak mean must be ≥ 10% above daily average
|
||||||
|
|
||||||
|
# Cross-Day Boundary Validation: overnight intervals must pass dual-day check
|
||||||
|
# For peak periods, intervals between 00:00 and this hour must ALSO qualify
|
||||||
|
# against the previous day's reference price. This prevents artifacts where
|
||||||
|
# overnight prices (e.g., 30ct) become "peak" against tomorrow's lower max
|
||||||
|
# but weren't peak against today's higher max.
|
||||||
|
CROSS_DAY_OVERNIGHT_VALIDATION_HOUR = 6 # Validate 00:00-05:59 against previous day too
|
||||||
|
|
||||||
# Log indentation levels for visual hierarchy
|
# Log indentation levels for visual hierarchy
|
||||||
INDENT_L0 = "" # Top level (calculate_periods_with_relaxation)
|
INDENT_L0 = "" # Top level (calculate_periods_with_relaxation)
|
||||||
INDENT_L1 = " " # Per-day loop
|
INDENT_L1 = " " # Per-day loop
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,11 @@
|
||||||
{
|
{
|
||||||
"domain": "tibber_prices",
|
"domain": "tibber_prices",
|
||||||
"name": "Tibber Price Information & Ratings",
|
"name": "Tibber Price Information & Ratings",
|
||||||
"codeowners": [
|
"codeowners": ["@jpawlowski"],
|
||||||
"@jpawlowski"
|
|
||||||
],
|
|
||||||
"config_flow": true,
|
"config_flow": true,
|
||||||
"documentation": "https://github.com/jpawlowski/hass.tibber_prices",
|
"documentation": "https://github.com/jpawlowski/hass.tibber_prices",
|
||||||
"iot_class": "cloud_polling",
|
"iot_class": "cloud_polling",
|
||||||
"issue_tracker": "https://github.com/jpawlowski/hass.tibber_prices/issues",
|
"issue_tracker": "https://github.com/jpawlowski/hass.tibber_prices/issues",
|
||||||
"requirements": [
|
"requirements": ["aiofiles>=23.2.1"],
|
||||||
"aiofiles>=23.2.1"
|
|
||||||
],
|
|
||||||
"version": "0.31.0b3"
|
"version": "0.31.0b3"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -148,7 +148,9 @@ class TestPeakPriceGenerationWorks:
|
||||||
|
|
||||||
# Bug validation: periods found (not 0)
|
# Bug validation: periods found (not 0)
|
||||||
assert len(periods) > 0, "Peak periods should generate after bug fix"
|
assert len(periods) > 0, "Peak periods should generate after bug fix"
|
||||||
assert 2 <= len(periods) <= 5, f"Expected 2-5 periods, got {len(periods)}"
|
# On flat days (IQR% ≤ 15%), min_periods adapts to 1 + weak peak filter
|
||||||
|
# may remove marginal peaks, so 1-5 periods is acceptable
|
||||||
|
assert 1 <= len(periods) <= 5, f"Expected 1-5 periods, got {len(periods)}"
|
||||||
|
|
||||||
def test_negative_flex_normalization_effect(self) -> None:
|
def test_negative_flex_normalization_effect(self) -> None:
|
||||||
"""
|
"""
|
||||||
|
|
@ -188,7 +190,8 @@ class TestPeakPriceGenerationWorks:
|
||||||
periods_pos = result_pos.get("periods", [])
|
periods_pos = result_pos.get("periods", [])
|
||||||
|
|
||||||
# With normalized positive flex, should find periods
|
# With normalized positive flex, should find periods
|
||||||
assert len(periods_pos) >= 2, f"Should find periods with positive flex, got {len(periods_pos)}"
|
# Flat day adaptation may reduce min_periods to 1
|
||||||
|
assert len(periods_pos) >= 1, f"Should find periods with positive flex, got {len(periods_pos)}"
|
||||||
|
|
||||||
def test_periods_contain_high_prices(self) -> None:
|
def test_periods_contain_high_prices(self) -> None:
|
||||||
"""
|
"""
|
||||||
|
|
@ -270,8 +273,8 @@ class TestPeakPriceGenerationWorks:
|
||||||
|
|
||||||
periods = result.get("periods", [])
|
periods = result.get("periods", [])
|
||||||
|
|
||||||
# Should find periods via relaxation
|
# Should find periods via relaxation (flat day may adapt to 1 period)
|
||||||
assert len(periods) >= 2, "Relaxation should find periods"
|
assert len(periods) >= 1, "Relaxation should find periods"
|
||||||
|
|
||||||
# Check if relaxation was used
|
# Check if relaxation was used
|
||||||
relaxation_meta = result.get("metadata", {}).get("relaxation", {})
|
relaxation_meta = result.get("metadata", {}).get("relaxation", {})
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue