From 90e2c3c1dc49a7cfa882e399ef9acfecb3662f09 Mon Sep 17 00:00:00 2001 From: Julian Pawlowski Date: Tue, 7 Apr 2026 13:44:01 +0000 Subject: [PATCH] feat(trend): add direction-group detection, noise floor, and confirmation hysteresis MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refactored trend calculator with direction-group-based trend change detection (rising/strongly_rising treated as same group, falling/strongly_falling as same group). Added minimum absolute price change thresholds (noise floor) to prevent spurious trends at low price levels. Both percentage AND absolute conditions must now be met. Updated strongly threshold defaults from ±6% to ±9% (3x base for perceptual scaling). Added missing strongly thresholds and new config keys to get_default_options(). calculate_price_trend() now returns volatility_factor as 4th tuple element for threshold transparency. Added CONF_PRICE_TREND_CHANGE_CONFIRMATION (default: 3 intervals = 45min) and CONF_PRICE_TREND_MIN_PRICE_CHANGE / _STRONGLY with validation limits. Updated tests for new 4-tuple return value. Impact: More stable trend detection — fewer false trend changes during low-price periods. Direction-group logic prevents noise from "rising ↔ strongly_rising" oscillations. Users can fine-tune noise floor for their market. --- custom_components/tibber_prices/const.py | 29 +- .../tibber_prices/sensor/calculators/trend.py | 467 +++++++++--------- .../tibber_prices/utils/price.py | 36 +- tests/test_percentage_calculations.py | 8 +- 4 files changed, 282 insertions(+), 258 deletions(-) diff --git a/custom_components/tibber_prices/const.py b/custom_components/tibber_prices/const.py index 614eb2b..2d816c5 100644 --- a/custom_components/tibber_prices/const.py +++ b/custom_components/tibber_prices/const.py @@ -52,6 +52,9 @@ CONF_PRICE_TREND_THRESHOLD_RISING = "price_trend_threshold_rising" CONF_PRICE_TREND_THRESHOLD_FALLING = "price_trend_threshold_falling" CONF_PRICE_TREND_THRESHOLD_STRONGLY_RISING = "price_trend_threshold_strongly_rising" CONF_PRICE_TREND_THRESHOLD_STRONGLY_FALLING = "price_trend_threshold_strongly_falling" +CONF_PRICE_TREND_CHANGE_CONFIRMATION = "price_trend_change_confirmation" +CONF_PRICE_TREND_MIN_PRICE_CHANGE = "price_trend_min_price_change" +CONF_PRICE_TREND_MIN_PRICE_CHANGE_STRONGLY = "price_trend_min_price_change_strongly" CONF_VOLATILITY_THRESHOLD_MODERATE = "volatility_threshold_moderate" CONF_VOLATILITY_THRESHOLD_HIGH = "volatility_threshold_high" CONF_VOLATILITY_THRESHOLD_VERY_HIGH = "volatility_threshold_very_high" @@ -103,10 +106,15 @@ DEFAULT_PRICE_LEVEL_GAP_TOLERANCE = 1 # Max consecutive intervals to smooth out DEFAULT_AVERAGE_SENSOR_DISPLAY = "median" # Default: show median in state, mean in attributes DEFAULT_PRICE_TREND_THRESHOLD_RISING = 3 # Default trend threshold for rising prices (%) DEFAULT_PRICE_TREND_THRESHOLD_FALLING = -3 # Default trend threshold for falling prices (%, negative value) -# Strong trend thresholds default to 2x the base threshold. -# These are independently configurable to allow fine-tuning of "strongly" detection. -DEFAULT_PRICE_TREND_THRESHOLD_STRONGLY_RISING = 6 # Default strong rising threshold (%) -DEFAULT_PRICE_TREND_THRESHOLD_STRONGLY_FALLING = -6 # Default strong falling threshold (%, negative value) +# Strong trend thresholds default to 3x the base threshold for perceptual scaling. +# The non-linear ratio (3% → 9%) ensures "strongly" feels significantly different from "rising/falling". +DEFAULT_PRICE_TREND_THRESHOLD_STRONGLY_RISING = 9 # Default strong rising threshold (%) +DEFAULT_PRICE_TREND_THRESHOLD_STRONGLY_FALLING = -9 # Default strong falling threshold (%, negative value) +DEFAULT_PRICE_TREND_CHANGE_CONFIRMATION = 3 # Default consecutive intervals to confirm trend change (3 x 15min = 45min) +DEFAULT_PRICE_TREND_MIN_PRICE_CHANGE = 0.005 # Minimum absolute price change for trend (in base currency, e.g. EUR/NOK) +DEFAULT_PRICE_TREND_MIN_PRICE_CHANGE_STRONGLY = ( + 0.015 # Minimum absolute price change for strong trend (in base currency) +) # Default volatility thresholds (relative values using coefficient of variation) # Coefficient of variation = (standard_deviation / mean) * 100% # These thresholds are unitless and work across different price levels @@ -172,6 +180,14 @@ MIN_PRICE_TREND_STRONGLY_RISING = 2 # Minimum strongly rising threshold (must b MAX_PRICE_TREND_STRONGLY_RISING = 100 # Maximum strongly rising threshold MIN_PRICE_TREND_STRONGLY_FALLING = -100 # Minimum strongly falling threshold (negative) MAX_PRICE_TREND_STRONGLY_FALLING = -2 # Maximum strongly falling threshold (must be < falling) +# Trend change confirmation limits (consecutive 15-min intervals) +MIN_PRICE_TREND_CHANGE_CONFIRMATION = 2 # Minimum: 2 intervals (30 min) - fast but more noise +MAX_PRICE_TREND_CHANGE_CONFIRMATION = 6 # Maximum: 6 intervals (90 min) - very stable but slow +# Minimum absolute price change thresholds (noise floor, in base currency e.g. EUR/NOK) +MIN_PRICE_TREND_MIN_PRICE_CHANGE = 0.0 # 0 = disabled (pure percentage mode) +MAX_PRICE_TREND_MIN_PRICE_CHANGE = 0.05 # 5 ct / 5 øre equivalent +MIN_PRICE_TREND_MIN_PRICE_CHANGE_STRONGLY = 0.0 # 0 = disabled +MAX_PRICE_TREND_MIN_PRICE_CHANGE_STRONGLY = 0.10 # 10 ct / 10 øre equivalent # Gap count and relaxation limits MIN_GAP_COUNT = 0 # Minimum gap count @@ -362,6 +378,11 @@ def get_default_options(currency_code: str | None) -> dict[str, Any]: # Price trend thresholds (flat - single-section step) CONF_PRICE_TREND_THRESHOLD_RISING: DEFAULT_PRICE_TREND_THRESHOLD_RISING, CONF_PRICE_TREND_THRESHOLD_FALLING: DEFAULT_PRICE_TREND_THRESHOLD_FALLING, + CONF_PRICE_TREND_THRESHOLD_STRONGLY_RISING: DEFAULT_PRICE_TREND_THRESHOLD_STRONGLY_RISING, + CONF_PRICE_TREND_THRESHOLD_STRONGLY_FALLING: DEFAULT_PRICE_TREND_THRESHOLD_STRONGLY_FALLING, + CONF_PRICE_TREND_CHANGE_CONFIRMATION: DEFAULT_PRICE_TREND_CHANGE_CONFIRMATION, + CONF_PRICE_TREND_MIN_PRICE_CHANGE: DEFAULT_PRICE_TREND_MIN_PRICE_CHANGE, + CONF_PRICE_TREND_MIN_PRICE_CHANGE_STRONGLY: DEFAULT_PRICE_TREND_MIN_PRICE_CHANGE_STRONGLY, # Nested section: Period settings (shared by best/peak price) "period_settings": { CONF_BEST_PRICE_MIN_PERIOD_LENGTH: DEFAULT_BEST_PRICE_MIN_PERIOD_LENGTH, diff --git a/custom_components/tibber_prices/sensor/calculators/trend.py b/custom_components/tibber_prices/sensor/calculators/trend.py index 6b08e5b..2572007 100644 --- a/custom_components/tibber_prices/sensor/calculators/trend.py +++ b/custom_components/tibber_prices/sensor/calculators/trend.py @@ -3,9 +3,9 @@ Trend calculator for price trend analysis sensors. This module handles all trend-related calculations: - Simple price trends (1h-12h future comparison) -- Current trend with momentum analysis -- Next trend change prediction -- Trend duration tracking +- Current trend (pure future-based 3h outlook with volatility adjustment) +- Next trend change prediction (with configurable N-interval hysteresis, default 3) +- Trend duration tracking (lightweight price direction scan with noise tolerance) Caching strategy: - Simple trends: Cached per sensor update to ensure consistency between state and attributes @@ -13,7 +13,7 @@ Caching strategy: """ from datetime import datetime -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, ClassVar from custom_components.tibber_prices.const import get_display_unit_factor from custom_components.tibber_prices.coordinator.helpers import get_intervals_for_day_offsets @@ -40,14 +40,25 @@ class TibberPricesTrendCalculator(TibberPricesBaseCalculator): Handles three types of trend analysis: 1. Simple trends (price_trend_1h-12h): Current vs next N hours average - 2. Current trend (current_price_trend): Momentum + 3h outlook with volatility adjustment - 3. Next change (next_price_trend_change): Scan forward for trend reversal + 2. Current trend (current_price_trend): Pure future-based 3h outlook with volatility adjustment + 3. Next change (next_price_trend_change): Scan forward with configurable N-interval hysteresis (default 3) Caching: - Simple trends: Per-sensor cache (_cached_trend_value, _trend_attributes) - Current/Next: Centralized cache (_trend_calculation_cache) with 60s TTL """ + # Direction groups for trend change detection. + # Only GROUP changes count as trend changes (not intensity changes within a group). + # E.g., rising → strongly_rising is NOT a change; rising → stable IS a change. + _DIRECTION_GROUPS: ClassVar[dict[str, str]] = { + "strongly_falling": "falling", + "falling": "falling", + "stable": "stable", + "rising": "rising", + "strongly_rising": "rising", + } + def __init__(self, coordinator: "TibberPricesDataUpdateCoordinator") -> None: """Initialize trend calculator with caching state.""" super().__init__(coordinator) @@ -103,30 +114,51 @@ class TibberPricesTrendCalculator(TibberPricesBaseCalculator): return None # Get configured thresholds from options - threshold_rising = self.config.get("price_trend_threshold_rising", 5.0) - threshold_falling = self.config.get("price_trend_threshold_falling", -5.0) - threshold_strongly_rising = self.config.get("price_trend_threshold_strongly_rising", 6.0) - threshold_strongly_falling = self.config.get("price_trend_threshold_strongly_falling", -6.0) + threshold_rising = self.config.get("price_trend_threshold_rising", 3.0) + threshold_falling = self.config.get("price_trend_threshold_falling", -3.0) + threshold_strongly_rising = self.config.get("price_trend_threshold_strongly_rising", 9.0) + threshold_strongly_falling = self.config.get("price_trend_threshold_strongly_falling", -9.0) volatility_threshold_moderate = self.config.get("volatility_threshold_moderate", 15.0) volatility_threshold_high = self.config.get("volatility_threshold_high", 30.0) + # Minimum absolute price change thresholds (noise floor) + # Config values are stored in base currency (EUR/NOK) - no conversion needed + min_abs_diff = self.config.get("price_trend_min_price_change", 0.005) + min_abs_diff_strongly = self.config.get("price_trend_min_price_change_strongly", 0.015) + # Prepare data for volatility-adaptive thresholds today_prices = self.intervals_today tomorrow_prices = self.intervals_tomorrow all_intervals = today_prices + tomorrow_prices lookahead_intervals = self.coordinator.time.minutes_to_intervals(hours * 60) + # Find current interval index to slice correct volatility window. + # Without this, _calculate_lookahead_volatility_factor() would analyze prices + # from the start of the day instead of the actual lookahead window. + current_idx = None + for idx, interval in enumerate(all_intervals): + if time.get_interval_time(interval) == current_starts_at: + current_idx = idx + break + + if current_idx is not None: + volatility_window = all_intervals[current_idx : current_idx + lookahead_intervals] + else: + volatility_window = all_intervals[:lookahead_intervals] + # Calculate trend with volatility-adaptive thresholds - trend_state, diff_pct, trend_value = calculate_price_trend( + trend_state, diff_pct, trend_value, vol_factor = calculate_price_trend( current_interval_price, future_mean, threshold_rising=threshold_rising, threshold_falling=threshold_falling, threshold_strongly_rising=threshold_strongly_rising, threshold_strongly_falling=threshold_strongly_falling, + min_abs_diff=min_abs_diff, + min_abs_diff_strongly=min_abs_diff_strongly, volatility_adjustment=True, # Always enabled lookahead_intervals=lookahead_intervals, - all_intervals=all_intervals, + all_intervals=volatility_window, volatility_threshold_moderate=volatility_threshold_moderate, volatility_threshold_high=volatility_threshold_high, ) @@ -145,14 +177,19 @@ class TibberPricesTrendCalculator(TibberPricesBaseCalculator): factor = get_display_unit_factor(self.config_entry) # Store attributes in sensor-specific dictionary AND cache the trend value + # Show effective thresholds (after volatility adjustment) so users can understand + # why a trend was detected even when diff_pct seems below configured thresholds self._trend_attributes = { "timestamp": next_interval_start, "trend_value": trend_value, f"trend_{hours}h_%": round(diff_pct, 1), f"next_{hours}h_avg": round(future_mean * factor, 2), "interval_count": lookahead_intervals, - "threshold_rising": threshold_rising, - "threshold_falling": threshold_falling, + "threshold_rising_%": round(threshold_rising * vol_factor, 1), + "threshold_rising_strongly_%": round(threshold_strongly_rising * vol_factor, 1), + "threshold_falling_%": round(threshold_falling * vol_factor, 1), + "threshold_falling_strongly_%": round(threshold_strongly_falling * vol_factor, 1), + "volatility_factor": vol_factor, "icon_color": icon_color, } @@ -191,9 +228,16 @@ class TibberPricesTrendCalculator(TibberPricesBaseCalculator): return None # Set attributes for this sensor + # Note: "previous_direction" (not "from_direction") because this shows the + # price direction BEFORE the current trend (binary: rising/falling), + # not the trend classification. next_price_trend_change uses "from_direction" + # for the current 5-level trend state. self._current_trend_attributes = { - "from_direction": trend_info["from_direction"], - "trend_duration_minutes": trend_info["trend_duration_minutes"], + "previous_direction": trend_info["from_direction"], + "price_direction_duration_minutes": trend_info["trend_duration_minutes"], + "price_direction_since": ( + trend_info["trend_start_time"].isoformat() if trend_info["trend_start_time"] else None + ), } return trend_info["current_trend_state"] @@ -218,6 +262,28 @@ class TibberPricesTrendCalculator(TibberPricesBaseCalculator): return trend_info["next_change_time"] + def get_trend_change_in_minutes_value(self) -> float | None: + """ + Calculate minutes until the next price trend change, as hours. + + Returns the same data as get_next_trend_change_value() but as a duration + in minutes (converted to hours by value_getters). Shares cached attributes + with the timestamp sensor. + + Returns: + Minutes until next trend change, or None if no change expected + + """ + trend_info = self._calculate_trend_info() + + if not trend_info: + return None + + # Share attributes with the timestamp sensor + self._trend_change_attributes = trend_info["trend_change_attributes"] + + return trend_info["minutes_until_change"] + def get_trend_attributes(self) -> dict[str, Any]: """Get cached trend attributes for simple trend sensors (price_trend_Nh).""" return self._trend_attributes @@ -339,53 +405,26 @@ class TibberPricesTrendCalculator(TibberPricesBaseCalculator): # Get configured thresholds thresholds = self._get_thresholds_config() - # Step 1: Calculate current momentum from trailing data (1h weighted) - current_price = float(current_interval["total"]) - current_momentum = self._calculate_momentum(current_price, all_intervals, current_index) + # Step 1: Calculate pure future-based 3h trend (no momentum) + current_trend_state = self._calculate_standard_trend(all_intervals, current_index, current_interval, thresholds) - # Step 2: Calculate 3h baseline trend for comparison - current_trend_3h = self._calculate_standard_trend(all_intervals, current_index, current_interval, thresholds) - - # Step 3: Calculate final trend FIRST (momentum + future outlook) - min_intervals_for_trend = 4 - standard_lookahead = 12 # 3 hours - lookahead_intervals = standard_lookahead - - # Get future data - future_intervals = all_intervals[current_index + 1 : current_index + lookahead_intervals + 1] - future_prices = [float(fi["total"]) for fi in future_intervals if "total" in fi] - - # Combine momentum + future outlook to get ACTUAL current trend - if len(future_intervals) >= min_intervals_for_trend and future_prices: - future_mean = calculate_mean(future_prices) - current_trend_state = self._combine_momentum_with_future( - current_momentum=current_momentum, - current_price=current_price, - future_mean=future_mean, - context={ - "all_intervals": all_intervals, - "current_index": current_index, - "lookahead_intervals": lookahead_intervals, - "thresholds": thresholds, - }, - ) - else: - # Not enough future data - use 3h baseline as fallback - current_trend_state = current_trend_3h - - # Step 4: Find next trend change FROM the current trend state (not momentum!) + # Step 2: Find next trend change by scanning forward scan_params = { "current_index": current_index, - "current_trend_state": current_trend_state, # Use FINAL trend, not momentum + "current_trend_state": current_trend_state, "current_interval": current_interval, "now": now, } next_change_time = self._scan_for_trend_change(all_intervals, scan_params, thresholds) - # Step 5: Find when current trend started (scan backward) + # Step 3: Find when current trend started (scan backward) + # Use min_abs_diff as noise tolerance to ignore tiny price jitter trend_start_time, from_direction = self._find_trend_start_time( - all_intervals, current_index, current_trend_state, thresholds + all_intervals, + current_index, + current_trend_state, + noise_tolerance=thresholds["min_abs_diff"], ) # Calculate duration of current trend @@ -420,133 +459,17 @@ class TibberPricesTrendCalculator(TibberPricesBaseCalculator): def _get_thresholds_config(self) -> dict[str, float]: """Get configured thresholds for trend calculation.""" return { - "rising": self.config.get("price_trend_threshold_rising", 5.0), - "falling": self.config.get("price_trend_threshold_falling", -5.0), - "strongly_rising": self.config.get("price_trend_threshold_strongly_rising", 6.0), - "strongly_falling": self.config.get("price_trend_threshold_strongly_falling", -6.0), + "rising": self.config.get("price_trend_threshold_rising", 3.0), + "falling": self.config.get("price_trend_threshold_falling", -3.0), + "strongly_rising": self.config.get("price_trend_threshold_strongly_rising", 9.0), + "strongly_falling": self.config.get("price_trend_threshold_strongly_falling", -9.0), "moderate": self.config.get("volatility_threshold_moderate", 15.0), "high": self.config.get("volatility_threshold_high", 30.0), + # Config values are stored in base currency (EUR/NOK) - no conversion needed + "min_abs_diff": self.config.get("price_trend_min_price_change", 0.005), + "min_abs_diff_strongly": self.config.get("price_trend_min_price_change_strongly", 0.015), } - def _calculate_momentum(self, current_price: float, all_intervals: list, current_index: int) -> str: - """ - Calculate price momentum from weighted trailing average (last 1h). - - Args: - current_price: Current interval price - all_intervals: All price intervals - current_index: Index of current interval - - Returns: - Momentum direction: "strongly_rising", "rising", "stable", "falling", or "strongly_falling" - - """ - # Look back 1 hour (4 intervals) for quick reaction - lookback_intervals = 4 - min_intervals = 2 # Need at least 30 minutes of history - - trailing_intervals = all_intervals[max(0, current_index - lookback_intervals) : current_index] - - if len(trailing_intervals) < min_intervals: - return "stable" # Not enough history - - # Weighted average: newer intervals count more - # Weights: [0.5, 0.75, 1.0, 1.25] for 4 intervals (grows linearly) - weights = [0.5 + 0.25 * i for i in range(len(trailing_intervals))] - trailing_prices = [float(interval["total"]) for interval in trailing_intervals if "total" in interval] - - if not trailing_prices or len(trailing_prices) != len(weights): - return "stable" - - weighted_sum = sum(price * weight for price, weight in zip(trailing_prices, weights, strict=True)) - weighted_avg = weighted_sum / sum(weights) - - # Calculate momentum with thresholds - # Using same logic as 5-level trend: 3% for normal, 6% (2x) for strong - momentum_threshold = 0.03 - strong_momentum_threshold = 0.06 - diff = (current_price - weighted_avg) / abs(weighted_avg) if weighted_avg != 0 else 0 - - # Determine momentum level based on thresholds - if diff >= strong_momentum_threshold: - momentum = "strongly_rising" - elif diff > momentum_threshold: - momentum = "rising" - elif diff <= -strong_momentum_threshold: - momentum = "strongly_falling" - elif diff < -momentum_threshold: - momentum = "falling" - else: - momentum = "stable" - - return momentum - - def _combine_momentum_with_future( - self, - *, - current_momentum: str, - current_price: float, - future_mean: float, - context: dict, - ) -> str: - """ - Combine momentum analysis with future outlook to determine final trend. - - Uses 5-level scale: strongly_rising, rising, stable, falling, strongly_falling. - Momentum intensity is preserved when future confirms the trend direction. - - Args: - current_momentum: Current momentum direction (5-level scale) - current_price: Current interval price - future_mean: Average price in future window - context: Dict with all_intervals, current_index, lookahead_intervals, thresholds - - Returns: - Final trend direction (5-level scale) - - """ - # Use calculate_price_trend for consistency with 5-level logic - all_intervals = context["all_intervals"] - current_index = context["current_index"] - lookahead_intervals = context["lookahead_intervals"] - thresholds = context["thresholds"] - - lookahead_for_volatility = all_intervals[current_index : current_index + lookahead_intervals] - future_trend, _, _ = calculate_price_trend( - current_price, - future_mean, - threshold_rising=thresholds["rising"], - threshold_falling=thresholds["falling"], - threshold_strongly_rising=thresholds["strongly_rising"], - threshold_strongly_falling=thresholds["strongly_falling"], - volatility_adjustment=True, - lookahead_intervals=lookahead_intervals, - all_intervals=lookahead_for_volatility, - volatility_threshold_moderate=thresholds["moderate"], - volatility_threshold_high=thresholds["high"], - ) - - # Check if momentum and future trend are aligned (same direction) - momentum_rising = current_momentum in ("rising", "strongly_rising") - momentum_falling = current_momentum in ("falling", "strongly_falling") - future_rising = future_trend in ("rising", "strongly_rising") - future_falling = future_trend in ("falling", "strongly_falling") - - if momentum_rising and future_rising: - # Both indicate rising - use the stronger signal - if current_momentum == "strongly_rising" or future_trend == "strongly_rising": - return "strongly_rising" - return "rising" - - if momentum_falling and future_falling: - # Both indicate falling - use the stronger signal - if current_momentum == "strongly_falling" or future_trend == "strongly_falling": - return "strongly_falling" - return "falling" - - # Conflicting signals or stable momentum - trust future trend calculation - return future_trend - def _calculate_standard_trend( self, all_intervals: list, @@ -571,13 +494,15 @@ class TibberPricesTrendCalculator(TibberPricesBaseCalculator): current_price = float(current_interval["total"]) standard_lookahead_volatility = all_intervals[current_index : current_index + standard_lookahead] - current_trend_3h, _, _ = calculate_price_trend( + current_trend_3h, _, _, _ = calculate_price_trend( current_price, standard_future_mean, threshold_rising=thresholds["rising"], threshold_falling=thresholds["falling"], threshold_strongly_rising=thresholds["strongly_rising"], threshold_strongly_falling=thresholds["strongly_falling"], + min_abs_diff=thresholds["min_abs_diff"], + min_abs_diff_strongly=thresholds["min_abs_diff_strongly"], volatility_adjustment=True, lookahead_intervals=standard_lookahead, all_intervals=standard_lookahead_volatility, @@ -601,73 +526,91 @@ class TibberPricesTrendCalculator(TibberPricesBaseCalculator): all_intervals: list, current_index: int, current_trend_state: str, - thresholds: dict, + *, + noise_tolerance: float = 0.0, ) -> tuple[datetime | None, str | None]: """ - Find when the current trend started by scanning backward. + Find when the current trend started by scanning backward for price direction change. + + Uses lightweight price comparison instead of recalculating full trend at each + past interval. The trend start is where the price direction changed — i.e., where + prices stopped moving in the current direction and started moving the other way. + + Price changes smaller than noise_tolerance (in base currency, e.g. EUR) are + ignored. This prevents tiny jitter (e.g. 0.1ct fluctuation in a 10ct→20ct + uptrend) from cutting the detected trend duration short. + + For "stable" trends, the start is where prices stopped rising or falling. Args: all_intervals: List of all price intervals current_index: Index of current interval - current_trend_state: Current trend state ("rising", "falling", "stable") - thresholds: Threshold configuration + current_trend_state: Current trend state (e.g., "rising", "falling", "stable") + noise_tolerance: Minimum absolute price change (base currency) to count as + a direction change. Defaults to 0.0 (no tolerance). Returns: Tuple of (start_time, from_direction): - start_time: When current trend began, or None if at data boundary - - from_direction: Previous trend direction, or None if unknown + - from_direction: Previous price direction, or None if unknown """ - intervals_in_3h = 12 # 3 hours = 12 intervals @ 15min each - - # Scan backward to find when trend changed TO current state time = self.coordinator.time - for i in range(current_index - 1, max(-1, current_index - 97), -1): + max_lookback = 97 # ~24h + + # Map current trend to expected price direction + is_rising = current_trend_state in ("rising", "strongly_rising") + is_falling = current_trend_state in ("falling", "strongly_falling") + + # Scan backward looking for where price direction changed + prev_price = float(all_intervals[current_index]["total"]) if "total" in all_intervals[current_index] else None + if prev_price is None: + return None, None + + for i in range(current_index - 1, max(-1, current_index - max_lookback), -1): if i < 0: break interval = all_intervals[i] + price = float(interval["total"]) if "total" in interval else None + if price is None: + continue + interval_start = time.get_interval_time(interval) if not interval_start: continue - # Calculate trend at this past interval - future_intervals = all_intervals[i + 1 : i + intervals_in_3h + 1] - if len(future_intervals) < intervals_in_3h: - break # Not enough data to calculate trend + # Calculate signed price difference: positive = price was rising, negative = falling + price_diff = prev_price - price - future_prices = [float(fi["total"]) for fi in future_intervals if "total" in fi] - if not future_prices: - continue + # Apply noise tolerance: ignore price changes below threshold + direction_was_rising = price_diff > noise_tolerance + direction_was_falling = price_diff < -noise_tolerance + # If |price_diff| <= noise_tolerance → neither → continue scanning - future_mean = calculate_mean(future_prices) - price = float(interval["total"]) - - # Calculate trend at this past point - lookahead_for_volatility = all_intervals[i : i + intervals_in_3h] - trend_state, _, _ = calculate_price_trend( - price, - future_mean, - threshold_rising=thresholds["rising"], - threshold_falling=thresholds["falling"], - threshold_strongly_rising=thresholds["strongly_rising"], - threshold_strongly_falling=thresholds["strongly_falling"], - volatility_adjustment=True, - lookahead_intervals=intervals_in_3h, - all_intervals=lookahead_for_volatility, - volatility_threshold_moderate=thresholds["moderate"], - volatility_threshold_high=thresholds["high"], - ) - - # Check if trend was different from current trend state - if trend_state != current_trend_state: - # Found the change point - the NEXT interval is where current trend started + # Check if direction contradicts current trend + if is_rising and direction_was_falling: + # Price was falling here, but we're currently rising → trend started at next interval next_interval = all_intervals[i + 1] trend_start = time.get_interval_time(next_interval) - if trend_start: - return trend_start, trend_state + return trend_start, "falling" - # Reached data boundary - current trend extends beyond available data + if is_falling and direction_was_rising: + # Price was rising here, but we're currently falling → trend started at next interval + next_interval = all_intervals[i + 1] + trend_start = time.get_interval_time(next_interval) + return trend_start, "rising" + + if not is_rising and not is_falling and (direction_was_rising or direction_was_falling): + # Current trend is "stable" — look for any clear directional movement + next_interval = all_intervals[i + 1] + trend_start = time.get_interval_time(next_interval) + from_dir = "rising" if direction_was_rising else "falling" + return trend_start, from_dir + + prev_price = price + + # Reached data boundary return None, None def _scan_for_trend_change( @@ -677,7 +620,11 @@ class TibberPricesTrendCalculator(TibberPricesBaseCalculator): thresholds: dict, ) -> datetime | None: """ - Scan future intervals for trend change. + Scan future intervals for trend change with hysteresis. + + Requires N consecutive intervals (configurable, default 3) showing a different + trend before confirming a change. This prevents false positives from short-lived + price spikes. Args: all_intervals: List of all price intervals @@ -685,16 +632,31 @@ class TibberPricesTrendCalculator(TibberPricesBaseCalculator): thresholds: Dict with rising, falling, moderate, high threshold values Returns: - Timestamp of next trend change, or None if no change in next 24h + Timestamp of first interval of confirmed trend change, or None if no change """ time = self.coordinator.time intervals_in_3h = 12 # 3 hours = 12 intervals @ 15min each + required_consecutive = int(self.config.get("price_trend_change_confirmation", 3)) + + # Reset attributes to prevent stale data from previous calculation. + # Without this, old attributes persist when no trend change is found, + # causing the sensor to show state=unknown with misleading old values. + self._trend_change_attributes = None + current_index = scan_params["current_index"] current_trend_state = scan_params["current_trend_state"] current_interval = scan_params["current_interval"] now = scan_params["now"] + # Use direction groups: only group changes count as trend changes. + # rising/strongly_rising → "rising", falling/strongly_falling → "falling", stable → "stable" + current_group = self._DIRECTION_GROUPS.get(current_trend_state, "stable") + + # Track consecutive intervals with different trend direction group + consecutive_different = 0 + first_change: dict[str, Any] | None = None # {index, trend, mean, diff, vol_factor} + for i in range(current_index + 1, min(current_index + 97, len(all_intervals))): interval = all_intervals[i] interval_start = time.get_interval_time(interval) @@ -719,13 +681,15 @@ class TibberPricesTrendCalculator(TibberPricesBaseCalculator): # Calculate trend at this future point lookahead_for_volatility = all_intervals[i : i + intervals_in_3h] - trend_state, _, _ = calculate_price_trend( + trend_state, trend_diff, _, vol_factor = calculate_price_trend( current_price, future_mean, threshold_rising=thresholds["rising"], threshold_falling=thresholds["falling"], threshold_strongly_rising=thresholds["strongly_rising"], threshold_strongly_falling=thresholds["strongly_falling"], + min_abs_diff=thresholds["min_abs_diff"], + min_abs_diff_strongly=thresholds["min_abs_diff_strongly"], volatility_adjustment=True, lookahead_intervals=intervals_in_3h, all_intervals=lookahead_for_volatility, @@ -733,25 +697,50 @@ class TibberPricesTrendCalculator(TibberPricesBaseCalculator): volatility_threshold_high=thresholds["high"], ) - # Check if trend changed from current trend state - # We want to find ANY change from current state, including changes to/from stable - if trend_state != current_trend_state: - # Store details for attributes - time = self.coordinator.time - minutes_until = int(time.minutes_until(interval_start)) + new_group = self._DIRECTION_GROUPS.get(trend_state, "stable") - # Convert prices to display currency unit - factor = get_display_unit_factor(self.config_entry) + if new_group != current_group: + consecutive_different += 1 + if consecutive_different == 1: + # Remember the first different interval (5-level state for attributes) + first_change = { + "index": i, + "trend": trend_state, + "mean": future_mean, + "diff": trend_diff, + "vol_factor": vol_factor, + } - self._trend_change_attributes = { - "direction": trend_state, - "from_direction": current_trend_state, - "minutes_until_change": minutes_until, - "current_price_now": round(float(current_interval["total"]) * factor, 2), - "price_at_change": round(current_price * factor, 2), - "avg_after_change": round(future_mean * factor, 2), - "trend_diff_%": round((future_mean - current_price) / current_price * 100, 1), - } - return interval_start + if consecutive_different >= required_consecutive and first_change is not None: + # Confirmed: N consecutive intervals show different trend direction + change_interval = all_intervals[first_change["index"]] + change_time = time.get_interval_time(change_interval) + if change_time: + change_price = float(change_interval["total"]) + minutes_until = int(time.minutes_until(change_time)) + factor = get_display_unit_factor(self.config_entry) + vf = first_change["vol_factor"] + + self._trend_change_attributes = { + "direction": first_change["trend"], + "from_direction": current_trend_state, + "minutes_until_change": minutes_until, + "price_now": round(float(current_interval["total"]) * factor, 2), + "price_at_change": round(change_price * factor, 2), + "price_avg_after_change": ( + round(first_change["mean"] * factor, 2) if first_change["mean"] else None + ), + "trend_diff_%": round(first_change["diff"], 1), + "threshold_rising_%": round(thresholds["rising"] * vf, 1), + "threshold_rising_strongly_%": round(thresholds["strongly_rising"] * vf, 1), + "threshold_falling_%": round(thresholds["falling"] * vf, 1), + "threshold_falling_strongly_%": round(thresholds["strongly_falling"] * vf, 1), + "volatility_factor": vf, + } + return change_time + else: + # Reset counter — trend matches current again + consecutive_different = 0 + first_change = None return None diff --git a/custom_components/tibber_prices/utils/price.py b/custom_components/tibber_prices/utils/price.py index f4adc0f..77e3f6a 100644 --- a/custom_components/tibber_prices/utils/price.py +++ b/custom_components/tibber_prices/utils/price.py @@ -1136,14 +1136,16 @@ def calculate_price_trend( # noqa: PLR0913 - All parameters are necessary for v threshold_rising: float = 3.0, threshold_falling: float = -3.0, *, - threshold_strongly_rising: float = 6.0, - threshold_strongly_falling: float = -6.0, + threshold_strongly_rising: float = 9.0, + threshold_strongly_falling: float = -9.0, + min_abs_diff: float = 0.0, + min_abs_diff_strongly: float = 0.0, volatility_adjustment: bool = True, lookahead_intervals: int | None = None, all_intervals: list[dict[str, Any]] | None = None, volatility_threshold_moderate: float = DEFAULT_VOLATILITY_THRESHOLD_MODERATE, volatility_threshold_high: float = DEFAULT_VOLATILITY_THRESHOLD_HIGH, -) -> tuple[str, float, int]: +) -> tuple[str, float, int, float]: """ Calculate price trend by comparing current price with future average. @@ -1165,6 +1167,10 @@ def calculate_price_trend( # noqa: PLR0913 - All parameters are necessary for v Uses the same volatility thresholds as configured for volatility sensors, ensuring consistent volatility interpretation across the integration. + Additionally supports minimum absolute price difference thresholds (noise floor) + to prevent tiny absolute changes from triggering trends at low price levels. + Both percentage AND absolute conditions must be met for a trend to be detected. + Args: current_interval_price: Current interval price future_average: Average price of future intervals @@ -1172,6 +1178,8 @@ def calculate_price_trend( # noqa: PLR0913 - All parameters are necessary for v threshold_falling: Base threshold for falling trend (%, negative, default -3%) threshold_strongly_rising: Threshold for strongly rising (%, positive, default 6%) threshold_strongly_falling: Threshold for strongly falling (%, negative, default -6%) + min_abs_diff: Minimum absolute price difference for rising/falling (base currency, 0=disabled) + min_abs_diff_strongly: Minimum absolute price difference for strongly rising/falling (base currency, 0=disabled) volatility_adjustment: Enable volatility-adaptive thresholds (default True) lookahead_intervals: Number of intervals in trend period for volatility calc all_intervals: Price intervals (today + tomorrow) for volatility calculation @@ -1179,10 +1187,11 @@ def calculate_price_trend( # noqa: PLR0913 - All parameters are necessary for v volatility_threshold_high: User-configured high volatility threshold (%) Returns: - Tuple of (trend_state, difference_percentage, trend_value) + Tuple of (trend_state, difference_percentage, trend_value, volatility_factor) trend_state: PRICE_TREND_* constant (e.g., "strongly_rising") difference_percentage: % change from current to future ((future - current) / current * 100) trend_value: Integer value from -2 to +2 for automation comparisons + volatility_factor: Applied multiplier (0.6/1.0/1.4) for threshold transparency Note: Volatility adjustment factor: @@ -1193,9 +1202,10 @@ def calculate_price_trend( # noqa: PLR0913 - All parameters are necessary for v """ if current_interval_price == 0: # Avoid division by zero - return stable trend - return PRICE_TREND_STABLE, 0.0, PRICE_TREND_MAPPING[PRICE_TREND_STABLE] + return PRICE_TREND_STABLE, 0.0, PRICE_TREND_MAPPING[PRICE_TREND_STABLE], 1.0 # Apply volatility adjustment if enabled and data available + volatility_factor = 1.0 effective_rising = threshold_rising effective_falling = threshold_falling effective_strongly_rising = threshold_strongly_rising @@ -1215,17 +1225,21 @@ def calculate_price_trend( # noqa: PLR0913 - All parameters are necessary for v # Example: current=-10, future=-5 → diff=5, pct=5/abs(-10)*100=+50% (correctly shows rising) diff_pct = ((future_average - current_interval_price) / abs(current_interval_price)) * 100 + # Calculate absolute price difference (for noise floor check) + abs_diff = abs(future_average - current_interval_price) + # Determine trend based on effective thresholds (5-level scale) - # Check "strongly" conditions first (more extreme), then regular conditions - if diff_pct >= effective_strongly_rising: + # Both percentage AND absolute minimum conditions must be met. + # This prevents tiny absolute changes (e.g., 0.15 ct at 5 ct/kWh) from triggering trends. + if diff_pct >= effective_strongly_rising and abs_diff >= min_abs_diff_strongly: trend = PRICE_TREND_STRONGLY_RISING - elif diff_pct >= effective_rising: + elif diff_pct >= effective_rising and abs_diff >= min_abs_diff: trend = PRICE_TREND_RISING - elif diff_pct <= effective_strongly_falling: + elif diff_pct <= effective_strongly_falling and abs_diff >= min_abs_diff_strongly: trend = PRICE_TREND_STRONGLY_FALLING - elif diff_pct <= effective_falling: + elif diff_pct <= effective_falling and abs_diff >= min_abs_diff: trend = PRICE_TREND_FALLING else: trend = PRICE_TREND_STABLE - return trend, diff_pct, PRICE_TREND_MAPPING[trend] + return trend, diff_pct, PRICE_TREND_MAPPING[trend], volatility_factor diff --git a/tests/test_percentage_calculations.py b/tests/test_percentage_calculations.py index 78c4ccf..930db42 100644 --- a/tests/test_percentage_calculations.py +++ b/tests/test_percentage_calculations.py @@ -102,7 +102,7 @@ def test_bug10_trend_diff_negative_current_price() -> None: threshold_strongly_rising = 20.0 threshold_strongly_falling = -20.0 - trend, diff_pct, trend_value = calculate_price_trend( + trend, diff_pct, trend_value, _ = calculate_price_trend( current_interval_price=current_interval_price, future_average=future_average, threshold_rising=threshold_rising, @@ -135,7 +135,7 @@ def test_bug10_trend_diff_negative_falling_deeper() -> None: threshold_strongly_rising = 20.0 threshold_strongly_falling = -20.0 - trend, diff_pct, trend_value = calculate_price_trend( + trend, diff_pct, trend_value, _ = calculate_price_trend( current_interval_price=current_interval_price, future_average=future_average, threshold_rising=threshold_rising, @@ -167,7 +167,7 @@ def test_bug10_trend_diff_zero_current_price() -> None: threshold_strongly_rising = 20.0 threshold_strongly_falling = -20.0 - trend, diff_pct, trend_value = calculate_price_trend( + trend, diff_pct, trend_value, _ = calculate_price_trend( current_interval_price=current_interval_price, future_average=future_average, threshold_rising=threshold_rising, @@ -196,7 +196,7 @@ def test_bug10_trend_diff_positive_prices_unchanged() -> None: threshold_strongly_rising = 20.0 threshold_strongly_falling = -20.0 - trend, diff_pct, trend_value = calculate_price_trend( + trend, diff_pct, trend_value, _ = calculate_price_trend( current_interval_price=current_interval_price, future_average=future_average, threshold_rising=threshold_rising,