mirror of
https://github.com/jpawlowski/hass.tibber_prices.git
synced 2026-05-28 18:43:40 +00:00
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:
parent
d0b6ea0e1a
commit
33f57ff077
7 changed files with 354 additions and 65 deletions
|
|
@ -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",
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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"])
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue