feat(sensors)!: rename price_trend_Xh → price_outlook_Xh, add price_trajectory_Xh

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.<home>_price_trend_Xh to sensor.<home>_price_outlook_Xh. New
price_trajectory_Xh sensors provide complementary direction information.
This commit is contained in:
Julian Pawlowski 2026-04-09 16:08:42 +00:00
parent d0b6ea0e1a
commit 33f57ff077
7 changed files with 354 additions and 65 deletions

View file

@ -69,14 +69,21 @@ STEP_TO_SENSOR_KEYS: dict[str, list[str]] = {
# Also affects trend sensors (adaptive thresholds) # Also affects trend sensors (adaptive thresholds)
"current_price_trend", "current_price_trend",
"next_price_trend_change", "next_price_trend_change",
"price_trend_1h", "price_outlook_1h",
"price_trend_2h", "price_outlook_2h",
"price_trend_3h", "price_outlook_3h",
"price_trend_4h", "price_outlook_4h",
"price_trend_5h", "price_outlook_5h",
"price_trend_6h", "price_outlook_6h",
"price_trend_8h", "price_outlook_8h",
"price_trend_12h", "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 settings affect best price binary sensor and timing sensors
"best_price": [ "best_price": [
@ -106,14 +113,21 @@ STEP_TO_SENSOR_KEYS: dict[str, list[str]] = {
"price_trend": [ "price_trend": [
"current_price_trend", "current_price_trend",
"next_price_trend_change", "next_price_trend_change",
"price_trend_1h", "price_outlook_1h",
"price_trend_2h", "price_outlook_2h",
"price_trend_3h", "price_outlook_3h",
"price_trend_4h", "price_outlook_4h",
"price_trend_5h", "price_outlook_5h",
"price_trend_6h", "price_outlook_6h",
"price_trend_8h", "price_outlook_8h",
"price_trend_12h", "price_outlook_12h",
"price_trajectory_2h",
"price_trajectory_3h",
"price_trajectory_4h",
"price_trajectory_5h",
"price_trajectory_6h",
"price_trajectory_8h",
"price_trajectory_12h",
], ],
} }

View file

@ -62,14 +62,22 @@ TIME_SENSITIVE_ENTITY_KEYS = frozenset(
"current_price_trend", "current_price_trend",
"next_price_trend_change", "next_price_trend_change",
# Price trend sensors # Price trend sensors
"price_trend_1h", "price_outlook_1h",
"price_trend_2h", "price_outlook_2h",
"price_trend_3h", "price_outlook_3h",
"price_trend_4h", "price_outlook_4h",
"price_trend_5h", "price_outlook_5h",
"price_trend_6h", "price_outlook_6h",
"price_trend_8h", "price_outlook_8h",
"price_trend_12h", "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/leading 24h calculations (based on current interval)
"trailing_price_average", "trailing_price_average",
"leading_price_average", "leading_price_average",

View file

@ -28,8 +28,10 @@ def _add_timing_or_volatility_attributes(
def _add_cached_trend_attributes(attributes: dict, key: str, cached_data: dict) -> None: def _add_cached_trend_attributes(attributes: dict, key: str, cached_data: dict) -> None:
"""Add cached trend attributes if available.""" """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"]) 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"): elif key == "current_price_trend" and cached_data.get("current_trend_attributes"):
# Add cached attributes (timestamp already set by platform) # Add cached attributes (timestamp already set by platform)
attributes.update(cached_data["current_trend_attributes"]) attributes.update(cached_data["current_trend_attributes"])

View file

@ -2,13 +2,14 @@
Trend calculator for price trend analysis sensors. Trend calculator for price trend analysis sensors.
This module handles all trend-related calculations: 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) - Current trend (pure future-based 3h outlook with volatility adjustment)
- Next trend change prediction (with configurable N-interval hysteresis, default 3) - Next trend change prediction (with configurable N-interval hysteresis, default 3)
- Trend duration tracking (lightweight price direction scan with noise tolerance) - Trend duration tracking (lightweight price direction scan with noise tolerance)
Caching strategy: 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 - Current trend + next change: Cached centrally for 60s to avoid duplicate calculations
""" """
@ -31,7 +32,7 @@ if TYPE_CHECKING:
) )
# Constants # 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): class TibberPricesTrendCalculator(TibberPricesBaseCalculator):
@ -39,9 +40,10 @@ class TibberPricesTrendCalculator(TibberPricesBaseCalculator):
Calculator for price trend sensors. Calculator for price trend sensors.
Handles three types of trend analysis: Handles three types of trend analysis:
1. Simple trends (price_trend_1h-12h): Current vs next N hours average 1. Outlook sensors (price_outlook_1h-12h): Current vs next N hours average
2. Current trend (current_price_trend): Pure future-based 3h outlook with volatility adjustment 2. Trajectory sensors (price_trajectory_2h-12h): First half vs second half of window
3. Next change (next_price_trend_change): Scan forward with configurable N-interval hysteresis (default 3) 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: Caching:
- Simple trends: Per-sensor cache (_cached_trend_value, _trend_attributes) - Simple trends: Per-sensor cache (_cached_trend_value, _trend_attributes)
@ -62,9 +64,10 @@ class TibberPricesTrendCalculator(TibberPricesBaseCalculator):
def __init__(self, coordinator: "TibberPricesDataUpdateCoordinator") -> None: def __init__(self, coordinator: "TibberPricesDataUpdateCoordinator") -> None:
"""Initialize trend calculator with caching state.""" """Initialize trend calculator with caching state."""
super().__init__(coordinator) 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._cached_trend_value: str | None = None
self._trend_attributes: dict[str, Any] = {} self._trend_attributes: dict[str, Any] = {}
self._trajectory_attributes: dict[str, Any] = {}
# Centralized trend calculation cache (for current_price_trend + next_price_trend_change) # Centralized trend calculation cache (for current_price_trend + next_price_trend_change)
self._trend_calculation_cache: dict[str, Any] | None = None self._trend_calculation_cache: dict[str, Any] | None = None
self._trend_calculation_timestamp: datetime | 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._current_trend_attributes: dict[str, Any] | None = None
self._trend_change_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. Results are cached per sensor to ensure consistency between state and attributes.
Args: Args:
@ -284,10 +288,131 @@ class TibberPricesTrendCalculator(TibberPricesBaseCalculator):
return trend_info["minutes_until_change"] 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]: 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 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: def get_current_trend_attributes(self) -> dict[str, Any] | None:
"""Get cached attributes for current_price_trend sensor.""" """Get cached attributes for current_price_trend sensor."""
return self._current_trend_attributes return self._current_trend_attributes
@ -297,9 +422,10 @@ class TibberPricesTrendCalculator(TibberPricesBaseCalculator):
return self._trend_change_attributes return self._trend_change_attributes
def clear_trend_cache(self) -> None: 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._cached_trend_value = None
self._trend_attributes = {} self._trend_attributes = {}
self._trajectory_attributes = {}
def clear_calculation_cache(self) -> None: def clear_calculation_cache(self) -> None:
"""Clear centralized trend calculation cache (called on coordinator update).""" """Clear centralized trend calculation cache (called on coordinator update)."""
@ -310,6 +436,54 @@ class TibberPricesTrendCalculator(TibberPricesBaseCalculator):
# PRIVATE HELPER METHODS # 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: 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. Calculate average price for the later half of the future time window.

View file

@ -326,7 +326,7 @@ class TibberPricesSensor(TibberPricesEntity, RestoreSensor):
self.coordinator.time = time_service self.coordinator.time = time_service
# Clear cached trend values on time-sensitive updates # 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() self._trend_calculator.clear_trend_cache()
# Clear trend calculation cache for trend sensors # Clear trend calculation cache for trend sensors
elif self.entity_description.key in ( elif self.entity_description.key in (
@ -366,7 +366,7 @@ class TibberPricesSensor(TibberPricesEntity, RestoreSensor):
def _handle_coordinator_update(self) -> None: def _handle_coordinator_update(self) -> None:
"""Handle updated data from the coordinator.""" """Handle updated data from the coordinator."""
# Clear cached trend values when coordinator data changes # 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() self._trend_calculator.clear_trend_cache()
# Also clear calculation cache (e.g., when threshold config changes) # Also clear calculation cache (e.g., when threshold config changes)
self._trend_calculator.clear_calculation_cache() self._trend_calculator.clear_calculation_cache()
@ -1140,6 +1140,7 @@ class TibberPricesSensor(TibberPricesEntity, RestoreSensor):
cached_data.update( cached_data.update(
{ {
"trend_attributes": self._trend_calculator.get_trend_attributes(), "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(), "current_trend_attributes": self._trend_calculator.get_current_trend_attributes(),
"trend_change_attributes": self._trend_calculator.get_trend_change_attributes(), "trend_change_attributes": self._trend_calculator.get_trend_change_attributes(),
"volatility_attributes": self._volatility_calculator.get_volatility_attributes(), "volatility_attributes": self._volatility_calculator.get_volatility_attributes(),

View file

@ -529,11 +529,11 @@ FUTURE_TREND_SENSORS = (
suggested_display_precision=2, suggested_display_precision=2,
entity_registry_enabled_default=True, 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 # Default enabled: 1h-5h
SensorEntityDescription( SensorEntityDescription(
key="price_trend_1h", key="price_outlook_1h",
translation_key="price_trend_1h", translation_key="price_outlook_1h",
icon="mdi:trending-up", # Dynamic: shows trending-up/trending-down/trending-neutral based on trend value icon="mdi:trending-up", # Dynamic: shows trending-up/trending-down/trending-neutral based on trend value
device_class=SensorDeviceClass.ENUM, device_class=SensorDeviceClass.ENUM,
state_class=None, # Enum values: no statistics state_class=None, # Enum values: no statistics
@ -541,8 +541,8 @@ FUTURE_TREND_SENSORS = (
entity_registry_enabled_default=True, entity_registry_enabled_default=True,
), ),
SensorEntityDescription( SensorEntityDescription(
key="price_trend_2h", key="price_outlook_2h",
translation_key="price_trend_2h", translation_key="price_outlook_2h",
icon="mdi:trending-up", # Dynamic: shows trending-up/trending-down/trending-neutral based on trend value icon="mdi:trending-up", # Dynamic: shows trending-up/trending-down/trending-neutral based on trend value
device_class=SensorDeviceClass.ENUM, device_class=SensorDeviceClass.ENUM,
state_class=None, # Enum values: no statistics state_class=None, # Enum values: no statistics
@ -550,8 +550,8 @@ FUTURE_TREND_SENSORS = (
entity_registry_enabled_default=True, entity_registry_enabled_default=True,
), ),
SensorEntityDescription( SensorEntityDescription(
key="price_trend_3h", key="price_outlook_3h",
translation_key="price_trend_3h", translation_key="price_outlook_3h",
icon="mdi:trending-up", # Dynamic: shows trending-up/trending-down/trending-neutral based on trend value icon="mdi:trending-up", # Dynamic: shows trending-up/trending-down/trending-neutral based on trend value
device_class=SensorDeviceClass.ENUM, device_class=SensorDeviceClass.ENUM,
state_class=None, # Enum values: no statistics state_class=None, # Enum values: no statistics
@ -559,8 +559,8 @@ FUTURE_TREND_SENSORS = (
entity_registry_enabled_default=True, entity_registry_enabled_default=True,
), ),
SensorEntityDescription( SensorEntityDescription(
key="price_trend_4h", key="price_outlook_4h",
translation_key="price_trend_4h", translation_key="price_outlook_4h",
icon="mdi:trending-up", # Dynamic: shows trending-up/trending-down/trending-neutral based on trend value icon="mdi:trending-up", # Dynamic: shows trending-up/trending-down/trending-neutral based on trend value
device_class=SensorDeviceClass.ENUM, device_class=SensorDeviceClass.ENUM,
state_class=None, # Enum values: no statistics state_class=None, # Enum values: no statistics
@ -568,8 +568,8 @@ FUTURE_TREND_SENSORS = (
entity_registry_enabled_default=True, entity_registry_enabled_default=True,
), ),
SensorEntityDescription( SensorEntityDescription(
key="price_trend_5h", key="price_outlook_5h",
translation_key="price_trend_5h", translation_key="price_outlook_5h",
icon="mdi:trending-up", # Dynamic: shows trending-up/trending-down/trending-neutral based on trend value icon="mdi:trending-up", # Dynamic: shows trending-up/trending-down/trending-neutral based on trend value
device_class=SensorDeviceClass.ENUM, device_class=SensorDeviceClass.ENUM,
state_class=None, # Enum values: no statistics state_class=None, # Enum values: no statistics
@ -578,8 +578,8 @@ FUTURE_TREND_SENSORS = (
), ),
# Disabled by default: 6h, 8h, 12h # Disabled by default: 6h, 8h, 12h
SensorEntityDescription( SensorEntityDescription(
key="price_trend_6h", key="price_outlook_6h",
translation_key="price_trend_6h", translation_key="price_outlook_6h",
icon="mdi:trending-up", # Dynamic: shows trending-up/trending-down/trending-neutral based on trend value icon="mdi:trending-up", # Dynamic: shows trending-up/trending-down/trending-neutral based on trend value
device_class=SensorDeviceClass.ENUM, device_class=SensorDeviceClass.ENUM,
state_class=None, # Enum values: no statistics state_class=None, # Enum values: no statistics
@ -587,8 +587,8 @@ FUTURE_TREND_SENSORS = (
entity_registry_enabled_default=False, entity_registry_enabled_default=False,
), ),
SensorEntityDescription( SensorEntityDescription(
key="price_trend_8h", key="price_outlook_8h",
translation_key="price_trend_8h", translation_key="price_outlook_8h",
icon="mdi:trending-up", # Dynamic: shows trending-up/trending-down/trending-neutral based on trend value icon="mdi:trending-up", # Dynamic: shows trending-up/trending-down/trending-neutral based on trend value
device_class=SensorDeviceClass.ENUM, device_class=SensorDeviceClass.ENUM,
state_class=None, # Enum values: no statistics state_class=None, # Enum values: no statistics
@ -596,8 +596,89 @@ FUTURE_TREND_SENSORS = (
entity_registry_enabled_default=False, entity_registry_enabled_default=False,
), ),
SensorEntityDescription( SensorEntityDescription(
key="price_trend_12h", key="price_outlook_12h",
translation_key="price_trend_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 icon="mdi:trending-up", # Dynamic: shows trending-up/trending-down/trending-neutral based on trend value
device_class=SensorDeviceClass.ENUM, device_class=SensorDeviceClass.ENUM,
state_class=None, # Enum values: no statistics state_class=None, # Enum values: no statistics
@ -970,6 +1051,7 @@ ENTITY_DESCRIPTIONS = (
*WINDOW_24H_SENSORS, *WINDOW_24H_SENSORS,
*FUTURE_MEAN_SENSORS, *FUTURE_MEAN_SENSORS,
*FUTURE_TREND_SENSORS, *FUTURE_TREND_SENSORS,
*PRICE_TRAJECTORY_SENSORS,
*VOLATILITY_SENSORS, *VOLATILITY_SENSORS,
*BEST_PRICE_TIMING_SENSORS, *BEST_PRICE_TIMING_SENSORS,
*PEAK_PRICE_TIMING_SENSORS, *PEAK_PRICE_TIMING_SENSORS,

View file

@ -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, "current_price_trend": trend_calculator.get_current_trend_value,
"next_price_trend_change": trend_calculator.get_next_trend_change_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()), "trend_change_in_minutes": lambda: _minutes_to_hours(trend_calculator.get_trend_change_in_minutes_value()),
# Price trend sensors # Price outlook sensors (current price vs average of next Xh)
"price_trend_1h": lambda: trend_calculator.get_price_trend_value(hours=1), "price_outlook_1h": lambda: trend_calculator.get_price_outlook_value(hours=1),
"price_trend_2h": lambda: trend_calculator.get_price_trend_value(hours=2), "price_outlook_2h": lambda: trend_calculator.get_price_outlook_value(hours=2),
"price_trend_3h": lambda: trend_calculator.get_price_trend_value(hours=3), "price_outlook_3h": lambda: trend_calculator.get_price_outlook_value(hours=3),
"price_trend_4h": lambda: trend_calculator.get_price_trend_value(hours=4), "price_outlook_4h": lambda: trend_calculator.get_price_outlook_value(hours=4),
"price_trend_5h": lambda: trend_calculator.get_price_trend_value(hours=5), "price_outlook_5h": lambda: trend_calculator.get_price_outlook_value(hours=5),
"price_trend_6h": lambda: trend_calculator.get_price_trend_value(hours=6), "price_outlook_6h": lambda: trend_calculator.get_price_outlook_value(hours=6),
"price_trend_8h": lambda: trend_calculator.get_price_trend_value(hours=8), "price_outlook_8h": lambda: trend_calculator.get_price_outlook_value(hours=8),
"price_trend_12h": lambda: trend_calculator.get_price_trend_value(hours=12), "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 # Diagnostic sensors
"data_timestamp": get_data_timestamp, "data_timestamp": get_data_timestamp,
# Data lifecycle status sensor # Data lifecycle status sensor