mirror of
https://github.com/jpawlowski/hass.tibber_prices.git
synced 2026-05-28 18:43:40 +00:00
refactor(coordinator): use IQR% as primary flat-day metric in period relaxation
Replace CV with IQR% as the primary indicator for flat-day detection
in _compute_day_effective_min(). CV is inflated by isolated price spikes
(a single spike at 2× the average pushes CV to 15-25% while the core
price band stays flat), causing the flat-day adaptation to be missed.
IQR% (spread of the central 50% of prices / median) is unaffected by
tail outliers and correctly identifies "flat core + spike" days.
Threshold: LOW_IQR_PCT_FLAT_DAY_THRESHOLD = 15.0%
- IQR% ≈ 1.35 × CV for symmetric data, so 15% ≈ old CV threshold of 10%
- Extra headroom catches flat days with a single outlier (IQR%~3%,
CV~20%) that were previously missed
CV retained as fallback for edge cases where iqr_pct is None
(near-zero or negative median prices).
Impact: Flat days with a single isolated price spike are now correctly
identified, reducing unnecessary relaxation iterations on those days.
This commit is contained in:
parent
51a62d712f
commit
b1e0245a60
1 changed files with 33 additions and 12 deletions
|
|
@ -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 custom_components.tibber_prices.utils.price import calculate_coefficient_of_variation
|
from custom_components.tibber_prices.utils.price import calculate_coefficient_of_variation, calculate_iqr_stats
|
||||||
|
|
||||||
from .period_overlap import (
|
from .period_overlap import (
|
||||||
recalculate_period_metadata,
|
recalculate_period_metadata,
|
||||||
|
|
@ -51,7 +51,10 @@ FLEX_WARNING_VSHAPE_RATIO = 0.5 # span/ref_price ratio below which a day is con
|
||||||
# On flat price days (low variation), it is unrealistic to require multiple distinct
|
# On flat price days (low variation), it is unrealistic to require multiple distinct
|
||||||
# best/peak price periods. Requiring 2+ periods would force relaxation to create
|
# best/peak price periods. Requiring 2+ periods would force relaxation to create
|
||||||
# artificial periods that don't represent genuine price structure.
|
# artificial periods that don't represent genuine price structure.
|
||||||
LOW_CV_FLAT_DAY_THRESHOLD = 10.0 # %: days with CV ≤ this need only 1 period
|
LOW_CV_FLAT_DAY_THRESHOLD = 10.0 # %: fallback when IQR% not available (near-zero or negative median)
|
||||||
|
# IQR% ≤ 15% ≈ CV ≤ 10% for clean data, but also catches "flat + isolated spike" days correctly:
|
||||||
|
# a single spike inflates CV to 15-25% while leaving IQR% near 0-5%.
|
||||||
|
LOW_IQR_PCT_FLAT_DAY_THRESHOLD = 15.0 # %: days with IQR% ≤ this need only 1 period
|
||||||
|
|
||||||
|
|
||||||
def _check_period_quality(
|
def _check_period_quality(
|
||||||
|
|
@ -448,11 +451,14 @@ def _compute_day_effective_min(
|
||||||
"""
|
"""
|
||||||
Compute per-day effective min_periods with flat-day adaptation.
|
Compute per-day effective min_periods with flat-day adaptation.
|
||||||
|
|
||||||
On days with very low price variation (CV ≤ LOW_CV_FLAT_DAY_THRESHOLD),
|
On days with very low price variation (IQR% ≤ LOW_IQR_PCT_FLAT_DAY_THRESHOLD),
|
||||||
requiring multiple distinct cheapest/peak periods is unrealistic. Finding
|
requiring multiple distinct cheapest/peak periods is unrealistic. Finding
|
||||||
ONE period is sufficient because there is no meaningful price structure that
|
ONE period is sufficient because there is no meaningful price structure that
|
||||||
would create natural multiple periods.
|
would create natural multiple periods.
|
||||||
|
|
||||||
|
Uses IQR% as primary metric (robust to isolated price spikes) with CV as
|
||||||
|
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 ONLY to BEST PRICE periods (reverse_sort=False). For PEAK PRICE
|
||||||
periods, full relaxation should run even on flat days because identifying the
|
periods, full relaxation should run even on flat days because identifying the
|
||||||
genuinely most expensive window requires the complete filter evaluation.
|
genuinely most expensive window requires the complete filter evaluation.
|
||||||
|
|
@ -471,7 +477,6 @@ def _compute_day_effective_min(
|
||||||
"""
|
"""
|
||||||
day_effective_min = {}
|
day_effective_min = {}
|
||||||
flat_day_count = 0
|
flat_day_count = 0
|
||||||
min_prices_for_cv = 2 # Need at least 2 prices to calculate CV
|
|
||||||
|
|
||||||
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 or reverse_sort:
|
||||||
|
|
@ -481,30 +486,46 @@ def _compute_day_effective_min(
|
||||||
|
|
||||||
price_values = [float(p["total"]) for p in day_prices if p.get("total") is not None]
|
price_values = [float(p["total"]) for p in day_prices if p.get("total") is not None]
|
||||||
|
|
||||||
if len(price_values) < min_prices_for_cv:
|
if len(price_values) < 2: # noqa: PLR2004 - need at least 2 prices for any metric
|
||||||
day_effective_min[day] = min_periods
|
day_effective_min[day] = min_periods
|
||||||
continue
|
continue
|
||||||
|
|
||||||
day_cv = calculate_coefficient_of_variation(price_values)
|
# Primary flat-day metric: IQR% is robust to isolated price spikes.
|
||||||
|
# A single spike inflates CV to 15-25% while leaving IQR% near 0-5%,
|
||||||
|
# so IQR correctly identifies "flat core + spike" days as flat.
|
||||||
|
iqr_stats = calculate_iqr_stats(price_values)
|
||||||
|
iqr_pct = iqr_stats["iqr_pct"] if iqr_stats else None
|
||||||
|
|
||||||
if day_cv is not None and day_cv <= LOW_CV_FLAT_DAY_THRESHOLD:
|
is_flat = False
|
||||||
|
flat_metric = ""
|
||||||
|
|
||||||
|
if iqr_pct is not None:
|
||||||
|
is_flat = iqr_pct <= LOW_IQR_PCT_FLAT_DAY_THRESHOLD
|
||||||
|
flat_metric = f"IQR%={iqr_pct:.1f}% ≤ {LOW_IQR_PCT_FLAT_DAY_THRESHOLD:.0f}%"
|
||||||
|
else:
|
||||||
|
# IQR% undefined (near-zero or negative median): fall back to CV
|
||||||
|
day_cv = calculate_coefficient_of_variation(price_values)
|
||||||
|
if day_cv is not None:
|
||||||
|
is_flat = day_cv <= LOW_CV_FLAT_DAY_THRESHOLD
|
||||||
|
flat_metric = f"CV={day_cv:.1f}% ≤ {LOW_CV_FLAT_DAY_THRESHOLD:.0f}% (IQR% N/A)"
|
||||||
|
|
||||||
|
if is_flat:
|
||||||
day_effective_min[day] = 1
|
day_effective_min[day] = 1
|
||||||
flat_day_count += 1
|
flat_day_count += 1
|
||||||
_LOGGER_DETAILS.debug(
|
_LOGGER_DETAILS.debug(
|
||||||
"%sDay %s: flat price profile (CV=%.1f%% ≤ %.1f%%) → min_periods relaxed to 1",
|
"%sDay %s: flat price profile (%s) → min_periods relaxed to 1",
|
||||||
INDENT_L1,
|
INDENT_L1,
|
||||||
day,
|
day,
|
||||||
day_cv,
|
flat_metric,
|
||||||
LOW_CV_FLAT_DAY_THRESHOLD,
|
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
day_effective_min[day] = min_periods
|
day_effective_min[day] = min_periods
|
||||||
|
|
||||||
if flat_day_count > 0:
|
if flat_day_count > 0:
|
||||||
_LOGGER.info(
|
_LOGGER.info(
|
||||||
"Adaptive min_periods: %d flat day(s) (CV ≤ %.0f%%) need only 1 period instead of %d",
|
"Adaptive min_periods: %d flat day(s) (IQR%% ≤ %.0f%%) need only 1 period instead of %d",
|
||||||
flat_day_count,
|
flat_day_count,
|
||||||
LOW_CV_FLAT_DAY_THRESHOLD,
|
LOW_IQR_PCT_FLAT_DAY_THRESHOLD,
|
||||||
min_periods,
|
min_periods,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue