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,