From b1e0245a60552ed463566fc6361ec7029e2ebed3 Mon Sep 17 00:00:00 2001 From: Julian Pawlowski Date: Sun, 12 Apr 2026 15:31:40 +0000 Subject: [PATCH] refactor(coordinator): use IQR% as primary flat-day metric in period relaxation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../coordinator/period_handlers/relaxation.py | 45 ++++++++++++++----- 1 file changed, 33 insertions(+), 12 deletions(-) diff --git a/custom_components/tibber_prices/coordinator/period_handlers/relaxation.py b/custom_components/tibber_prices/coordinator/period_handlers/relaxation.py index 131a839..4006a29 100644 --- a/custom_components/tibber_prices/coordinator/period_handlers/relaxation.py +++ b/custom_components/tibber_prices/coordinator/period_handlers/relaxation.py @@ -12,7 +12,7 @@ if TYPE_CHECKING: 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 ( 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 # best/peak price periods. Requiring 2+ periods would force relaxation to create # 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( @@ -448,11 +451,14 @@ def _compute_day_effective_min( """ 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 ONE period is sufficient because there is no meaningful price structure that 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 periods, full relaxation should run even on flat days because identifying the genuinely most expensive window requires the complete filter evaluation. @@ -471,7 +477,6 @@ def _compute_day_effective_min( """ day_effective_min = {} 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(): 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] - 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 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 flat_day_count += 1 _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, day, - day_cv, - LOW_CV_FLAT_DAY_THRESHOLD, + flat_metric, ) else: day_effective_min[day] = min_periods if flat_day_count > 0: _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, - LOW_CV_FLAT_DAY_THRESHOLD, + LOW_IQR_PCT_FLAT_DAY_THRESHOLD, min_periods, )