From 33f57ff077d85a514cfb80b8b6627419fa94e2c5 Mon Sep 17 00:00:00 2001 From: Julian Pawlowski Date: Thu, 9 Apr 2026 16:08:42 +0000 Subject: [PATCH] =?UTF-8?q?feat(sensors)!:=20rename=20price=5Ftrend=5FXh?= =?UTF-8?q?=20=E2=86=92=20price=5Foutlook=5FXh,=20add=20price=5Ftrajectory?= =?UTF-8?q?=5FXh?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Renamed 8 sensors to clarify what they actually measure, and added 7 new sensors for a different (and often more useful) calculation. --- WHY THE RENAME --- The old name "price_trend_Xh" implied the sensor shows where prices are heading. It doesn't — it compares CURRENT price vs the FUTURE WINDOW AVERAGE. At a price minimum, it shows "strongly_falling" (because the cheap minimum pulls the average below your current high price), which is the opposite of intuitive. The name "price_outlook_Xh" correctly conveys: "is now cheaper or more expensive than the next Nh on average?" --- NEW: price_trajectory_Xh --- These sensors compare FIRST HALF vs SECOND HALF of the window, revealing actual price direction within the window: price_trajectory_2h: avg(hour 1) vs avg(hour 2) price_trajectory_3h: avg(first 1.5h) vs avg(second 1.5h) price_trajectory_4h: avg(first 2h) vs avg(second 2h) price_trajectory_5h: avg(first 2.5h) vs avg(second 2.5h) price_trajectory_6h: avg(first 3h) vs avg(second 3h) price_trajectory_8h: avg(first 4h) vs avg(second 4h) price_trajectory_12h: avg(first 6h) vs avg(second 6h) The key use case: at a price minimum, price_outlook_Xh shows "strongly_falling" but price_trajectory_Xh shows "rising" — correctly revealing the upcoming reversal. "outlook: falling + trajectory: rising" = you're AT the minimum. --- IMPLEMENTATION --- sensor/calculators/trend.py: - get_price_outlook_value() (was: get_price_trend_value()) - New: get_price_trajectory_value(*, hours: int) - New: _calculate_first_half_average(hours, next_interval_start) - New: get_trajectory_attributes() → first_half_avg, second_half_avg, half_diff_% - clear_trend_cache() also resets _trajectory_attributes sensor/definitions.py: - 8 SensorEntityDescription entries: key/translation_key price_trend_Xh → price_outlook_Xh - New PRICE_TRAJECTORY_SENSORS tuple (2h–5h enabled by default, 6h/8h/12h disabled) sensor/value_getters.py: - 8 lambda entries renamed - 7 new trajectory lambda entries added sensor/attributes/trend.py: - startswith("price_trend_") → startswith("price_outlook_") - New elif branch routing price_trajectory_* to cached trajectory_attributes sensor/core.py: - startswith checks updated for both prefix families - cached_data dict extended with "trajectory_attributes" coordinator/constants.py: - TIME_SENSITIVE_ENTITY_KEYS: 8 renamed + 7 new trajectory keys added config_flow_handlers/entity_check.py: - volatility + price_trend affected-entity lists: 8 renamed + 7 new BREAKING CHANGE: Sensors price_trend_1h, price_trend_2h, price_trend_3h, price_trend_4h, price_trend_5h, price_trend_6h, price_trend_8h, price_trend_12h have been removed without a deprecation period. Migration: Replace price_trend_Xh → price_outlook_Xh everywhere (automations, dashboards, templates). Behavior is identical — only the entity name changed. If you want to detect actual price direction within the window (e.g. "are prices rising or falling right now?"), use the new price_trajectory_Xh sensors instead. Impact: Users must update automations and dashboards. Entity IDs change from sensor._price_trend_Xh to sensor._price_outlook_Xh. New price_trajectory_Xh sensors provide complementary direction information. --- .../config_flow_handlers/entity_check.py | 46 ++-- .../tibber_prices/coordinator/constants.py | 24 ++- .../tibber_prices/sensor/attributes/trend.py | 4 +- .../tibber_prices/sensor/calculators/trend.py | 198 ++++++++++++++++-- .../tibber_prices/sensor/core.py | 5 +- .../tibber_prices/sensor/definitions.py | 116 ++++++++-- .../tibber_prices/sensor/value_getters.py | 26 ++- 7 files changed, 354 insertions(+), 65 deletions(-) diff --git a/custom_components/tibber_prices/config_flow_handlers/entity_check.py b/custom_components/tibber_prices/config_flow_handlers/entity_check.py index 4597bfd..1c13d94 100644 --- a/custom_components/tibber_prices/config_flow_handlers/entity_check.py +++ b/custom_components/tibber_prices/config_flow_handlers/entity_check.py @@ -69,14 +69,21 @@ STEP_TO_SENSOR_KEYS: dict[str, list[str]] = { # Also affects trend sensors (adaptive thresholds) "current_price_trend", "next_price_trend_change", - "price_trend_1h", - "price_trend_2h", - "price_trend_3h", - "price_trend_4h", - "price_trend_5h", - "price_trend_6h", - "price_trend_8h", - "price_trend_12h", + "price_outlook_1h", + "price_outlook_2h", + "price_outlook_3h", + "price_outlook_4h", + "price_outlook_5h", + "price_outlook_6h", + "price_outlook_8h", + "price_outlook_12h", + "price_trajectory_2h", + "price_trajectory_3h", + "price_trajectory_4h", + "price_trajectory_5h", + "price_trajectory_6h", + "price_trajectory_8h", + "price_trajectory_12h", ], # Best Price settings affect best price binary sensor and timing sensors "best_price": [ @@ -106,14 +113,21 @@ STEP_TO_SENSOR_KEYS: dict[str, list[str]] = { "price_trend": [ "current_price_trend", "next_price_trend_change", - "price_trend_1h", - "price_trend_2h", - "price_trend_3h", - "price_trend_4h", - "price_trend_5h", - "price_trend_6h", - "price_trend_8h", - "price_trend_12h", + "price_outlook_1h", + "price_outlook_2h", + "price_outlook_3h", + "price_outlook_4h", + "price_outlook_5h", + "price_outlook_6h", + "price_outlook_8h", + "price_outlook_12h", + "price_trajectory_2h", + "price_trajectory_3h", + "price_trajectory_4h", + "price_trajectory_5h", + "price_trajectory_6h", + "price_trajectory_8h", + "price_trajectory_12h", ], } diff --git a/custom_components/tibber_prices/coordinator/constants.py b/custom_components/tibber_prices/coordinator/constants.py index 8fd6133..df7085b 100644 --- a/custom_components/tibber_prices/coordinator/constants.py +++ b/custom_components/tibber_prices/coordinator/constants.py @@ -62,14 +62,22 @@ TIME_SENSITIVE_ENTITY_KEYS = frozenset( "current_price_trend", "next_price_trend_change", # Price trend sensors - "price_trend_1h", - "price_trend_2h", - "price_trend_3h", - "price_trend_4h", - "price_trend_5h", - "price_trend_6h", - "price_trend_8h", - "price_trend_12h", + "price_outlook_1h", + "price_outlook_2h", + "price_outlook_3h", + "price_outlook_4h", + "price_outlook_5h", + "price_outlook_6h", + "price_outlook_8h", + "price_outlook_12h", + # Price trajectory sensors (first-half vs second-half window comparison) + "price_trajectory_2h", + "price_trajectory_3h", + "price_trajectory_4h", + "price_trajectory_5h", + "price_trajectory_6h", + "price_trajectory_8h", + "price_trajectory_12h", # Trailing/leading 24h calculations (based on current interval) "trailing_price_average", "leading_price_average", diff --git a/custom_components/tibber_prices/sensor/attributes/trend.py b/custom_components/tibber_prices/sensor/attributes/trend.py index d853606..98d30ec 100644 --- a/custom_components/tibber_prices/sensor/attributes/trend.py +++ b/custom_components/tibber_prices/sensor/attributes/trend.py @@ -28,8 +28,10 @@ def _add_timing_or_volatility_attributes( def _add_cached_trend_attributes(attributes: dict, key: str, cached_data: dict) -> None: """Add cached trend attributes if available.""" - if key.startswith("price_trend_") and cached_data.get("trend_attributes"): + if key.startswith("price_outlook_") and cached_data.get("trend_attributes"): attributes.update(cached_data["trend_attributes"]) + elif key.startswith("price_trajectory_") and cached_data.get("trajectory_attributes"): + attributes.update(cached_data["trajectory_attributes"]) elif key == "current_price_trend" and cached_data.get("current_trend_attributes"): # Add cached attributes (timestamp already set by platform) attributes.update(cached_data["current_trend_attributes"]) diff --git a/custom_components/tibber_prices/sensor/calculators/trend.py b/custom_components/tibber_prices/sensor/calculators/trend.py index 2572007..60e5958 100644 --- a/custom_components/tibber_prices/sensor/calculators/trend.py +++ b/custom_components/tibber_prices/sensor/calculators/trend.py @@ -2,13 +2,14 @@ Trend calculator for price trend analysis sensors. This module handles all trend-related calculations: -- Simple price trends (1h-12h future comparison) +- Price outlook (1h-12h): Current price vs average of the next N hours +- Price trajectory (2h-12h): First-half vs second-half average in the window (shows turning points) - 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 +- Outlook/Trajectory: Cached per sensor update to ensure consistency between state and attributes - Current trend + next change: Cached centrally for 60s to avoid duplicate calculations """ @@ -31,7 +32,7 @@ if TYPE_CHECKING: ) # Constants -MIN_HOURS_FOR_LATER_HALF = 3 # Minimum hours needed to calculate later half average +MIN_HOURS_FOR_LATER_HALF = 1 # Minimum hours needed to calculate half-window averages (activates at 2h+) class TibberPricesTrendCalculator(TibberPricesBaseCalculator): @@ -39,9 +40,10 @@ class TibberPricesTrendCalculator(TibberPricesBaseCalculator): Calculator for price trend sensors. 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): Pure future-based 3h outlook with volatility adjustment - 3. Next change (next_price_trend_change): Scan forward with configurable N-interval hysteresis (default 3) + 1. Outlook sensors (price_outlook_1h-12h): Current vs next N hours average + 2. Trajectory sensors (price_trajectory_2h-12h): First half vs second half of window + 3. Current trend (current_price_trend): Pure future-based 3h outlook with volatility adjustment + 4. 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) @@ -62,9 +64,10 @@ class TibberPricesTrendCalculator(TibberPricesBaseCalculator): def __init__(self, coordinator: "TibberPricesDataUpdateCoordinator") -> None: """Initialize trend calculator with caching state.""" super().__init__(coordinator) - # Per-sensor trend caches (for price_trend_Nh sensors) + # Per-sensor caches (for price_outlook_Xh and price_trajectory_Xh sensors) self._cached_trend_value: str | None = None self._trend_attributes: dict[str, Any] = {} + self._trajectory_attributes: dict[str, Any] = {} # Centralized trend calculation cache (for current_price_trend + next_price_trend_change) self._trend_calculation_cache: dict[str, Any] | None = None self._trend_calculation_timestamp: datetime | None = None @@ -72,11 +75,12 @@ class TibberPricesTrendCalculator(TibberPricesBaseCalculator): self._current_trend_attributes: dict[str, Any] | None = None self._trend_change_attributes: dict[str, Any] | None = None - def get_price_trend_value(self, *, hours: int) -> str | None: + def get_price_outlook_value(self, *, hours: int) -> str | None: """ - Calculate price trend comparing current interval vs next N hours average. + Calculate price outlook comparing current interval vs average of the next N hours. - This is for simple trend sensors (price_trend_1h through price_trend_12h). + This is for price_outlook_Xh sensors. Answers: "Is the average of the next Xh + cheaper or more expensive than right now?" Results are cached per sensor to ensure consistency between state and attributes. Args: @@ -284,10 +288,131 @@ class TibberPricesTrendCalculator(TibberPricesBaseCalculator): return trend_info["minutes_until_change"] + def get_price_trajectory_value(self, *, hours: int) -> str | None: + """ + Calculate price trajectory by comparing first-half vs second-half window average. + + This is for price_trajectory_Xh sensors. Answers: "Are prices rising or falling + within the next Xh window?" — revealing turning points that price_outlook_Xh misses. + + Example at a price minimum (12:00): + - price_outlook_4h: "strongly_falling" (Ø next 4h is below current high) + - price_trajectory_4h: "rising" (second half is more expensive than first half) + → Combined signal: act now, reversal is coming within the window. + + Args: + hours: Number of hours in the window (must be >= 2) + + Returns: + Trend state: "rising" | "falling" | "stable", or None if unavailable + + """ + if hours < 2: # noqa: PLR2004 + return None + + if not self.has_data(): + return None + + current_interval = self.coordinator.get_current_interval() + if not current_interval or "total" not in current_interval: + return None + + current_interval_price = float(current_interval["total"]) + time = self.coordinator.time + current_starts_at = time.get_interval_time(current_interval) + if current_starts_at is None: + return None + + next_interval_start = time.get_next_interval_start() + + # Get first-half and second-half averages + first_half_avg = self._calculate_first_half_average(hours, next_interval_start) + second_half_avg = self._calculate_later_half_average(hours, next_interval_start) + + if first_half_avg is None or second_half_avg is None: + return None + + # Get configured thresholds (same as outlook sensors for consistency) + 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) + 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) + + # Build volatility window from full outlook period + 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) + + 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] + + # Compare first half vs second half: does price rise or fall across the window? + trajectory_state, diff_pct, trend_value, vol_factor = calculate_price_trend( + first_half_avg, + second_half_avg, + 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, + lookahead_intervals=lookahead_intervals, + all_intervals=volatility_window, + volatility_threshold_moderate=volatility_threshold_moderate, + volatility_threshold_high=volatility_threshold_high, + ) + + factor = get_display_unit_factor(self.config_entry) + time_obj = self.coordinator.time + total_intervals = time_obj.minutes_to_intervals(hours * 60) + first_half_count = total_intervals // 2 + second_half_count = total_intervals - first_half_count + + self._trajectory_attributes = { + "timestamp": next_interval_start, + "trend_value": trend_value, + f"trajectory_{hours}h_%": round(diff_pct, 1), + f"first_half_{hours}h_avg": round(first_half_avg * factor, 2), + f"second_half_{hours}h_avg": round(second_half_avg * factor, 2), + f"first_half_{hours}h_diff_from_current_%": round( + ((first_half_avg - current_interval_price) / abs(current_interval_price)) * 100, 1 + ) + if current_interval_price != 0 + else None, + f"second_half_{hours}h_diff_from_current_%": round( + ((second_half_avg - current_interval_price) / abs(current_interval_price)) * 100, 1 + ) + if current_interval_price != 0 + else None, + "first_half_interval_count": first_half_count, + "second_half_interval_count": second_half_count, + "volatility_factor": vol_factor, + } + + return trajectory_state + def get_trend_attributes(self) -> dict[str, Any]: - """Get cached trend attributes for simple trend sensors (price_trend_Nh).""" + """Get cached outlook attributes for price_outlook_Xh sensors.""" return self._trend_attributes + def get_trajectory_attributes(self) -> dict[str, Any]: + """Get cached trajectory attributes for price_trajectory_Xh sensors.""" + return self._trajectory_attributes + def get_current_trend_attributes(self) -> dict[str, Any] | None: """Get cached attributes for current_price_trend sensor.""" return self._current_trend_attributes @@ -297,9 +422,10 @@ class TibberPricesTrendCalculator(TibberPricesBaseCalculator): return self._trend_change_attributes def clear_trend_cache(self) -> None: - """Clear simple trend cache (called on coordinator update).""" + """Clear outlook/trajectory trend cache (called on coordinator update).""" self._cached_trend_value = None self._trend_attributes = {} + self._trajectory_attributes = {} def clear_calculation_cache(self) -> None: """Clear centralized trend calculation cache (called on coordinator update).""" @@ -310,6 +436,54 @@ class TibberPricesTrendCalculator(TibberPricesBaseCalculator): # PRIVATE HELPER METHODS # ======================================================================== + def _calculate_first_half_average(self, hours: int, next_interval_start: datetime) -> float | None: + """ + Calculate average price for the first half of the future time window. + + This is the counterpart to _calculate_later_half_average and together they + enable trajectory calculation (first half vs second half comparison). + + Args: + hours: Total hours in the prediction window + next_interval_start: Start timestamp of the next interval + + Returns: + Average price for the first half intervals, or None if insufficient data + + """ + if not self.has_data(): + return None + + today_prices = self.intervals_today + tomorrow_prices = self.intervals_tomorrow + all_prices = today_prices + tomorrow_prices + + if not all_prices: + return None + + time = self.coordinator.time + total_intervals = time.minutes_to_intervals(hours * 60) + first_half_intervals = total_intervals // 2 + interval_duration = time.get_interval_duration() + first_half_end = next_interval_start + (interval_duration * first_half_intervals) + + # Collect prices in the first half: [next_interval_start, first_half_end) + first_prices = [] + for price_data in all_prices: + starts_at = time.get_interval_time(price_data) + if starts_at is None: + continue + + if next_interval_start <= starts_at < first_half_end: + price = price_data.get("total") + if price is not None: + first_prices.append(float(price)) + + if first_prices: + return calculate_mean(first_prices) + + return None + def _calculate_later_half_average(self, hours: int, next_interval_start: datetime) -> float | None: """ Calculate average price for the later half of the future time window. diff --git a/custom_components/tibber_prices/sensor/core.py b/custom_components/tibber_prices/sensor/core.py index d6f4256..4e2c2d5 100644 --- a/custom_components/tibber_prices/sensor/core.py +++ b/custom_components/tibber_prices/sensor/core.py @@ -326,7 +326,7 @@ class TibberPricesSensor(TibberPricesEntity, RestoreSensor): self.coordinator.time = time_service # Clear cached trend values on time-sensitive updates - if self.entity_description.key.startswith("price_trend_"): + if self.entity_description.key.startswith(("price_outlook_", "price_trajectory_")): self._trend_calculator.clear_trend_cache() # Clear trend calculation cache for trend sensors elif self.entity_description.key in ( @@ -366,7 +366,7 @@ class TibberPricesSensor(TibberPricesEntity, RestoreSensor): def _handle_coordinator_update(self) -> None: """Handle updated data from the coordinator.""" # Clear cached trend values when coordinator data changes - if self.entity_description.key.startswith("price_trend_"): + if self.entity_description.key.startswith(("price_outlook_", "price_trajectory_")): self._trend_calculator.clear_trend_cache() # Also clear calculation cache (e.g., when threshold config changes) self._trend_calculator.clear_calculation_cache() @@ -1140,6 +1140,7 @@ class TibberPricesSensor(TibberPricesEntity, RestoreSensor): cached_data.update( { "trend_attributes": self._trend_calculator.get_trend_attributes(), + "trajectory_attributes": self._trend_calculator.get_trajectory_attributes(), "current_trend_attributes": self._trend_calculator.get_current_trend_attributes(), "trend_change_attributes": self._trend_calculator.get_trend_change_attributes(), "volatility_attributes": self._volatility_calculator.get_volatility_attributes(), diff --git a/custom_components/tibber_prices/sensor/definitions.py b/custom_components/tibber_prices/sensor/definitions.py index 8dd956e..cfdf7e3 100644 --- a/custom_components/tibber_prices/sensor/definitions.py +++ b/custom_components/tibber_prices/sensor/definitions.py @@ -529,11 +529,11 @@ FUTURE_TREND_SENSORS = ( suggested_display_precision=2, entity_registry_enabled_default=True, ), - # Price trend forecast sensors (will prices be higher/lower in X hours?) + # Price outlook forecast sensors (is the average of the next Xh cheaper/more expensive than now?) # Default enabled: 1h-5h SensorEntityDescription( - key="price_trend_1h", - translation_key="price_trend_1h", + key="price_outlook_1h", + translation_key="price_outlook_1h", icon="mdi:trending-up", # Dynamic: shows trending-up/trending-down/trending-neutral based on trend value device_class=SensorDeviceClass.ENUM, state_class=None, # Enum values: no statistics @@ -541,8 +541,8 @@ FUTURE_TREND_SENSORS = ( entity_registry_enabled_default=True, ), SensorEntityDescription( - key="price_trend_2h", - translation_key="price_trend_2h", + key="price_outlook_2h", + translation_key="price_outlook_2h", icon="mdi:trending-up", # Dynamic: shows trending-up/trending-down/trending-neutral based on trend value device_class=SensorDeviceClass.ENUM, state_class=None, # Enum values: no statistics @@ -550,8 +550,8 @@ FUTURE_TREND_SENSORS = ( entity_registry_enabled_default=True, ), SensorEntityDescription( - key="price_trend_3h", - translation_key="price_trend_3h", + key="price_outlook_3h", + translation_key="price_outlook_3h", icon="mdi:trending-up", # Dynamic: shows trending-up/trending-down/trending-neutral based on trend value device_class=SensorDeviceClass.ENUM, state_class=None, # Enum values: no statistics @@ -559,8 +559,8 @@ FUTURE_TREND_SENSORS = ( entity_registry_enabled_default=True, ), SensorEntityDescription( - key="price_trend_4h", - translation_key="price_trend_4h", + key="price_outlook_4h", + translation_key="price_outlook_4h", icon="mdi:trending-up", # Dynamic: shows trending-up/trending-down/trending-neutral based on trend value device_class=SensorDeviceClass.ENUM, state_class=None, # Enum values: no statistics @@ -568,8 +568,8 @@ FUTURE_TREND_SENSORS = ( entity_registry_enabled_default=True, ), SensorEntityDescription( - key="price_trend_5h", - translation_key="price_trend_5h", + key="price_outlook_5h", + translation_key="price_outlook_5h", icon="mdi:trending-up", # Dynamic: shows trending-up/trending-down/trending-neutral based on trend value device_class=SensorDeviceClass.ENUM, state_class=None, # Enum values: no statistics @@ -578,8 +578,8 @@ FUTURE_TREND_SENSORS = ( ), # Disabled by default: 6h, 8h, 12h SensorEntityDescription( - key="price_trend_6h", - translation_key="price_trend_6h", + key="price_outlook_6h", + translation_key="price_outlook_6h", icon="mdi:trending-up", # Dynamic: shows trending-up/trending-down/trending-neutral based on trend value device_class=SensorDeviceClass.ENUM, state_class=None, # Enum values: no statistics @@ -587,8 +587,8 @@ FUTURE_TREND_SENSORS = ( entity_registry_enabled_default=False, ), SensorEntityDescription( - key="price_trend_8h", - translation_key="price_trend_8h", + key="price_outlook_8h", + translation_key="price_outlook_8h", icon="mdi:trending-up", # Dynamic: shows trending-up/trending-down/trending-neutral based on trend value device_class=SensorDeviceClass.ENUM, state_class=None, # Enum values: no statistics @@ -596,8 +596,89 @@ FUTURE_TREND_SENSORS = ( entity_registry_enabled_default=False, ), SensorEntityDescription( - key="price_trend_12h", - translation_key="price_trend_12h", + key="price_outlook_12h", + translation_key="price_outlook_12h", + icon="mdi:trending-up", # Dynamic: shows trending-up/trending-down/trending-neutral based on trend value + device_class=SensorDeviceClass.ENUM, + state_class=None, # Enum values: no statistics + options=["strongly_falling", "falling", "stable", "rising", "strongly_rising"], + entity_registry_enabled_default=False, + ), +) + +# ---------------------------------------------------------------------------- +# 5b. PRICE TRAJECTORY SENSORS (first-half vs second-half window comparison) +# ---------------------------------------------------------------------------- +# These sensors reveal turning points: is the price rising or falling WITHIN +# the window? Complements price_outlook_Xh sensors. +# +# Example at a price minimum (12:00): +# - price_outlook_4h: "strongly_falling" (Ø next 4h is below current high) +# - price_trajectory_4h: "rising" (second half avg > first half avg) +# → Combined: act now, reversal is coming within the window. +# +# Coverage starts at 2h (minimum for meaningful first/second half split). +# Default enabled: 2h-5h + +PRICE_TRAJECTORY_SENSORS = ( + SensorEntityDescription( + key="price_trajectory_2h", + translation_key="price_trajectory_2h", + icon="mdi:trending-up", # Dynamic: shows trending-up/trending-down/trending-neutral based on trend value + device_class=SensorDeviceClass.ENUM, + state_class=None, # Enum values: no statistics + options=["strongly_falling", "falling", "stable", "rising", "strongly_rising"], + entity_registry_enabled_default=True, + ), + SensorEntityDescription( + key="price_trajectory_3h", + translation_key="price_trajectory_3h", + icon="mdi:trending-up", # Dynamic: shows trending-up/trending-down/trending-neutral based on trend value + device_class=SensorDeviceClass.ENUM, + state_class=None, # Enum values: no statistics + options=["strongly_falling", "falling", "stable", "rising", "strongly_rising"], + entity_registry_enabled_default=True, + ), + SensorEntityDescription( + key="price_trajectory_4h", + translation_key="price_trajectory_4h", + icon="mdi:trending-up", # Dynamic: shows trending-up/trending-down/trending-neutral based on trend value + device_class=SensorDeviceClass.ENUM, + state_class=None, # Enum values: no statistics + options=["strongly_falling", "falling", "stable", "rising", "strongly_rising"], + entity_registry_enabled_default=True, + ), + SensorEntityDescription( + key="price_trajectory_5h", + translation_key="price_trajectory_5h", + icon="mdi:trending-up", # Dynamic: shows trending-up/trending-down/trending-neutral based on trend value + device_class=SensorDeviceClass.ENUM, + state_class=None, # Enum values: no statistics + options=["strongly_falling", "falling", "stable", "rising", "strongly_rising"], + entity_registry_enabled_default=True, + ), + # Disabled by default: 6h, 8h, 12h + SensorEntityDescription( + key="price_trajectory_6h", + translation_key="price_trajectory_6h", + icon="mdi:trending-up", # Dynamic: shows trending-up/trending-down/trending-neutral based on trend value + device_class=SensorDeviceClass.ENUM, + state_class=None, # Enum values: no statistics + options=["strongly_falling", "falling", "stable", "rising", "strongly_rising"], + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key="price_trajectory_8h", + translation_key="price_trajectory_8h", + icon="mdi:trending-up", # Dynamic: shows trending-up/trending-down/trending-neutral based on trend value + device_class=SensorDeviceClass.ENUM, + state_class=None, # Enum values: no statistics + options=["strongly_falling", "falling", "stable", "rising", "strongly_rising"], + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key="price_trajectory_12h", + translation_key="price_trajectory_12h", icon="mdi:trending-up", # Dynamic: shows trending-up/trending-down/trending-neutral based on trend value device_class=SensorDeviceClass.ENUM, state_class=None, # Enum values: no statistics @@ -970,6 +1051,7 @@ ENTITY_DESCRIPTIONS = ( *WINDOW_24H_SENSORS, *FUTURE_MEAN_SENSORS, *FUTURE_TREND_SENSORS, + *PRICE_TRAJECTORY_SENSORS, *VOLATILITY_SENSORS, *BEST_PRICE_TIMING_SENSORS, *PEAK_PRICE_TIMING_SENSORS, diff --git a/custom_components/tibber_prices/sensor/value_getters.py b/custom_components/tibber_prices/sensor/value_getters.py index d29d0f9..6816f9e 100644 --- a/custom_components/tibber_prices/sensor/value_getters.py +++ b/custom_components/tibber_prices/sensor/value_getters.py @@ -206,15 +206,23 @@ def get_value_getter_mapping( # noqa: PLR0913 - needs all calculators as parame "current_price_trend": trend_calculator.get_current_trend_value, "next_price_trend_change": trend_calculator.get_next_trend_change_value, "trend_change_in_minutes": lambda: _minutes_to_hours(trend_calculator.get_trend_change_in_minutes_value()), - # Price trend sensors - "price_trend_1h": lambda: trend_calculator.get_price_trend_value(hours=1), - "price_trend_2h": lambda: trend_calculator.get_price_trend_value(hours=2), - "price_trend_3h": lambda: trend_calculator.get_price_trend_value(hours=3), - "price_trend_4h": lambda: trend_calculator.get_price_trend_value(hours=4), - "price_trend_5h": lambda: trend_calculator.get_price_trend_value(hours=5), - "price_trend_6h": lambda: trend_calculator.get_price_trend_value(hours=6), - "price_trend_8h": lambda: trend_calculator.get_price_trend_value(hours=8), - "price_trend_12h": lambda: trend_calculator.get_price_trend_value(hours=12), + # Price outlook sensors (current price vs average of next Xh) + "price_outlook_1h": lambda: trend_calculator.get_price_outlook_value(hours=1), + "price_outlook_2h": lambda: trend_calculator.get_price_outlook_value(hours=2), + "price_outlook_3h": lambda: trend_calculator.get_price_outlook_value(hours=3), + "price_outlook_4h": lambda: trend_calculator.get_price_outlook_value(hours=4), + "price_outlook_5h": lambda: trend_calculator.get_price_outlook_value(hours=5), + "price_outlook_6h": lambda: trend_calculator.get_price_outlook_value(hours=6), + "price_outlook_8h": lambda: trend_calculator.get_price_outlook_value(hours=8), + "price_outlook_12h": lambda: trend_calculator.get_price_outlook_value(hours=12), + # Price trajectory sensors (first-half vs second-half window, reveals turning points) + "price_trajectory_2h": lambda: trend_calculator.get_price_trajectory_value(hours=2), + "price_trajectory_3h": lambda: trend_calculator.get_price_trajectory_value(hours=3), + "price_trajectory_4h": lambda: trend_calculator.get_price_trajectory_value(hours=4), + "price_trajectory_5h": lambda: trend_calculator.get_price_trajectory_value(hours=5), + "price_trajectory_6h": lambda: trend_calculator.get_price_trajectory_value(hours=6), + "price_trajectory_8h": lambda: trend_calculator.get_price_trajectory_value(hours=8), + "price_trajectory_12h": lambda: trend_calculator.get_price_trajectory_value(hours=12), # Diagnostic sensors "data_timestamp": get_data_timestamp, # Data lifecycle status sensor