mirror of
https://github.com/jpawlowski/hass.tibber_prices.git
synced 2026-03-30 13:23:41 +00:00
Added intelligent price trend analysis combining historical momentum (weighted 1h lookback) with future outlook for more accurate trend recognition. Introduced two complementary sensors for comprehensive trend monitoring. New sensors: - current_price_trend: Shows active trend direction with duration - next_price_trend_change: Predicts when trend will reverse Momentum analysis (historical perspective): - Weighted 1h lookback (4 × 15-min intervals) - Linear weight progression [0.5, 0.75, 1.0, 1.25] - ±3% threshold for momentum classification - Recognizes ongoing trends earlier than future-only analysis Two-phase trend calculation: - Phase 1: Calculate momentum from weighted trailing average - Phase 2: Validate with volatility-adaptive future comparison - Combines both for final trend determination (rising/falling/stable) - Centralized in _calculate_trend_info() with 60s cache Volatility-adaptive thresholds: - Existing trend sensors (1h-12h) now use adaptive thresholds - calculate_price_trend() adjusted by market volatility: * LOW volatility (<15% CV): factor 0.6 → more sensitive (e.g., 3%→1.8%) * MODERATE volatility (15-30%): factor 1.0 → baseline (3%) * HIGH volatility (≥30%): factor 1.4 → less sensitive (e.g., 3%→4.2%) - Uses same coefficient of variation as volatility sensors - Ensures mathematical consistency across integration Default threshold reduction: - Rising/falling thresholds: 5% → 3% (more responsive) - Momentum-based detection enables lower thresholds without noise - Adaptive adjustment compensates during high volatility Architectural improvements: - Centralized calculation: Single source of truth for both sensors - Eliminates Henne-Ei problem (duplicate calculations) - 60-second cache per coordinator update - Shared helper methods: _calculate_momentum(), _combine_momentum_with_future() Translation updates (all 5 languages): - Documented momentum feature in custom_translations (de/en/nb/nl/sv) - Explained "recognizes ongoing trends earlier" advantage - Added sensor names and state options to standard translations - Updated volatility threshold descriptions (clarify usage by trend sensors) Files changed: - custom_components/tibber_prices/sensor/core.py (930 lines added) * New: _calculate_momentum(), _combine_momentum_with_future() * New: _calculate_trend_info() (centralized with cache) * New: _get_current_trend_value(), _get_next_trend_change_value() * Modified: _get_price_trend_value() (volatility-adaptive thresholds) - custom_components/tibber_prices/sensor/definitions.py * Added: current_price_trend (ENUM sensor) * Added: next_price_trend_change (TIMESTAMP sensor) - custom_components/tibber_prices/sensor/attributes.py * New: _add_cached_trend_attributes() helper * Support for current_trend_attributes, trend_change_attributes - custom_components/tibber_prices/price_utils.py (178 lines added) * New: _calculate_lookahead_volatility_factor() * Modified: calculate_price_trend() with volatility adjustment * Added: VOLATILITY_FACTOR_* constants (0.6/1.0/1.4) - custom_components/tibber_prices/entity_utils/icons.py * Added: Dynamic icon handling for next_price_trend_change - custom_components/tibber_prices/const.py * Changed: DEFAULT_PRICE_TREND_THRESHOLD_RISING/FALLING (5→3%) - custom_components/tibber_prices/translations/*.json (5 files) * Added: Sensor names, state options, descriptions - custom_components/tibber_prices/custom_translations/*.json (5 files) * Added: Long descriptions with momentum feature explanation Impact: Users get significantly more accurate trend detection that understands they're ALREADY in a trend, not just predicting future changes. Momentum-based approach recognizes ongoing movements 15-60 minutes earlier. Adaptive thresholds prevent false signals during volatile periods. Two complementary sensors enable both status display (current trend) and event-based automation (when will it change). Perfect for use cases like "charge EV when next trend change shows falling prices" or dashboard badges showing "Rising for 2.5h".
373 lines
12 KiB
Python
373 lines
12 KiB
Python
"""Icon utilities for Tibber Prices entities."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from collections.abc import Callable
|
|
from dataclasses import dataclass
|
|
from datetime import timedelta
|
|
from typing import TYPE_CHECKING, Any
|
|
|
|
from custom_components.tibber_prices.const import (
|
|
BINARY_SENSOR_ICON_MAPPING,
|
|
PRICE_LEVEL_CASH_ICON_MAPPING,
|
|
PRICE_LEVEL_ICON_MAPPING,
|
|
PRICE_RATING_ICON_MAPPING,
|
|
VOLATILITY_ICON_MAPPING,
|
|
)
|
|
from custom_components.tibber_prices.price_utils import find_price_data_for_interval
|
|
from custom_components.tibber_prices.sensor.helpers import (
|
|
aggregate_level_data,
|
|
find_rolling_hour_center_index,
|
|
)
|
|
from homeassistant.util import dt as dt_util
|
|
|
|
|
|
@dataclass
|
|
class IconContext:
|
|
"""Context data for dynamic icon selection."""
|
|
|
|
is_on: bool | None = None
|
|
coordinator_data: dict | None = None
|
|
has_future_periods_callback: Callable[[], bool] | None = None
|
|
period_is_active_callback: Callable[[], bool] | None = None
|
|
|
|
|
|
if TYPE_CHECKING:
|
|
from collections.abc import Callable
|
|
|
|
# Constants imported from price_utils
|
|
MINUTES_PER_INTERVAL = 15
|
|
|
|
# Timing sensor icon thresholds (in minutes)
|
|
TIMING_URGENT_THRESHOLD = 15 # ≤15 min: Alert icon
|
|
TIMING_SOON_THRESHOLD = 60 # ≤1 hour: Timer icon
|
|
TIMING_MEDIUM_THRESHOLD = 180 # ≤3 hours: Sand timer icon
|
|
# >3 hours: Outline timer icon
|
|
|
|
# Progress sensor constants
|
|
PROGRESS_MAX = 100 # Maximum progress value (100%)
|
|
|
|
|
|
def get_dynamic_icon(
|
|
key: str,
|
|
value: Any,
|
|
*,
|
|
context: IconContext | None = None,
|
|
) -> str | None:
|
|
"""
|
|
Get dynamic icon based on sensor state.
|
|
|
|
Unified function for both sensor and binary_sensor platforms.
|
|
|
|
Args:
|
|
key: Entity description key
|
|
value: Native value of the sensor
|
|
context: Optional context with is_on state, coordinator_data, and callbacks
|
|
|
|
Returns:
|
|
Icon string or None if no dynamic icon applies
|
|
|
|
"""
|
|
ctx = context or IconContext()
|
|
|
|
# Try various icon sources in order
|
|
return (
|
|
get_trend_icon(key, value)
|
|
or get_timing_sensor_icon(key, value, period_is_active_callback=ctx.period_is_active_callback)
|
|
or get_price_sensor_icon(key, ctx.coordinator_data)
|
|
or get_level_sensor_icon(key, value)
|
|
or get_rating_sensor_icon(key, value)
|
|
or get_volatility_sensor_icon(key, value)
|
|
or get_binary_sensor_icon(key, is_on=ctx.is_on, has_future_periods_callback=ctx.has_future_periods_callback)
|
|
)
|
|
|
|
|
|
def get_trend_icon(key: str, value: Any) -> str | None:
|
|
"""Get icon for trend sensors."""
|
|
# Handle next_price_trend_change TIMESTAMP sensor differently
|
|
# (icon based on attributes, not value which is a timestamp)
|
|
if key == "next_price_trend_change":
|
|
return None # Will be handled by sensor's icon property using attributes
|
|
|
|
if not key.startswith("price_trend_") or not isinstance(value, str):
|
|
return None
|
|
|
|
trend_icons = {
|
|
"rising": "mdi:trending-up",
|
|
"falling": "mdi:trending-down",
|
|
"stable": "mdi:trending-neutral",
|
|
}
|
|
return trend_icons.get(value)
|
|
|
|
|
|
def get_timing_sensor_icon(
|
|
key: str,
|
|
value: Any,
|
|
*,
|
|
period_is_active_callback: Callable[[], bool] | None = None,
|
|
) -> str | None:
|
|
"""
|
|
Get dynamic icon for best_price/peak_price timing sensors.
|
|
|
|
Progress sensors: Different icons based on period state
|
|
- No period: mdi:help-circle-outline (Unknown/gray)
|
|
- Waiting (0%, period not active): mdi:timer-pause-outline (paused/waiting)
|
|
- Active (0%, period running): mdi:circle-outline (just started)
|
|
- Progress 1-99%: mdi:circle-slice-1 to mdi:circle-slice-7
|
|
- Complete (100%): mdi:circle-slice-8
|
|
|
|
Remaining/Next-in sensors: Different timer icons based on time remaining
|
|
Timestamp sensors: Static icons (handled by entity description)
|
|
|
|
Args:
|
|
key: Entity description key
|
|
value: Sensor value (percentage for progress, minutes for countdown)
|
|
period_is_active_callback: Callback to check if related period is currently active
|
|
|
|
Returns:
|
|
Icon string or None if not a timing sensor with dynamic icon
|
|
|
|
"""
|
|
# Unknown state: Show help icon for all timing sensors
|
|
if value is None and key.startswith(("best_price_", "peak_price_")):
|
|
return "mdi:help-circle-outline"
|
|
|
|
# Progress sensors: Circle-slice icons for visual progress indication
|
|
# mdi:circle-slice-N where N represents filled portions (1=12.5%, 8=100%)
|
|
if key.endswith("_progress") and isinstance(value, (int, float)):
|
|
# Special handling for 0%: Distinguish between waiting and active
|
|
if value <= 0:
|
|
# Check if period is currently active via callback
|
|
is_active = (
|
|
period_is_active_callback()
|
|
if (period_is_active_callback and callable(period_is_active_callback))
|
|
else True
|
|
)
|
|
# Period just started (0% but running) vs waiting for next
|
|
return "mdi:circle-outline" if is_active else "mdi:timer-pause-outline"
|
|
|
|
# Calculate slice based on progress percentage
|
|
slice_num = 8 if value >= PROGRESS_MAX else min(7, max(1, int((value / PROGRESS_MAX) * 8)))
|
|
return f"mdi:circle-slice-{slice_num}"
|
|
|
|
# Remaining/Next-in minutes sensors: Timer icons based on urgency thresholds
|
|
if key.endswith(("_remaining_minutes", "_next_in_minutes")) and isinstance(value, (int, float)):
|
|
# Map time remaining to appropriate timer icon
|
|
urgency_map = [
|
|
(0, "mdi:timer-off-outline"), # Exactly 0 minutes
|
|
(TIMING_URGENT_THRESHOLD, "mdi:timer-alert"), # < 15 min: urgent
|
|
(TIMING_SOON_THRESHOLD, "mdi:timer"), # < 60 min: soon
|
|
(TIMING_MEDIUM_THRESHOLD, "mdi:timer-sand"), # < 180 min: medium
|
|
]
|
|
for threshold, icon in urgency_map:
|
|
if value <= threshold:
|
|
return icon
|
|
return "mdi:timer-outline" # >= 180 min: far away
|
|
|
|
# Timestamp sensors use static icons from entity description
|
|
return None
|
|
|
|
|
|
def get_price_sensor_icon(key: str, coordinator_data: dict | None) -> str | None:
|
|
"""
|
|
Get icon for current price sensors (dynamic based on price level).
|
|
|
|
Dynamic icons for: current_interval_price, next_interval_price,
|
|
current_hour_average_price, next_hour_average_price
|
|
Other price sensors (previous interval) use static icons from entity description.
|
|
|
|
Args:
|
|
key: Entity description key
|
|
coordinator_data: Coordinator data for price level lookups
|
|
|
|
Returns:
|
|
Icon string or None if not a current price sensor
|
|
|
|
"""
|
|
if not coordinator_data:
|
|
return None
|
|
|
|
# Only current price sensors get dynamic icons
|
|
if key == "current_interval_price":
|
|
level = get_price_level_for_icon(coordinator_data, interval_offset=0)
|
|
if level:
|
|
return PRICE_LEVEL_CASH_ICON_MAPPING.get(level.upper())
|
|
elif key == "next_interval_price":
|
|
# For next interval, use the next interval price level to determine icon
|
|
level = get_price_level_for_icon(coordinator_data, interval_offset=1)
|
|
if level:
|
|
return PRICE_LEVEL_CASH_ICON_MAPPING.get(level.upper())
|
|
elif key == "current_hour_average_price":
|
|
# For current hour average, use the current hour price level to determine icon
|
|
level = get_rolling_hour_price_level_for_icon(coordinator_data, hour_offset=0)
|
|
if level:
|
|
return PRICE_LEVEL_CASH_ICON_MAPPING.get(level.upper())
|
|
elif key == "next_hour_average_price":
|
|
# For next hour average, use the next hour price level to determine icon
|
|
level = get_rolling_hour_price_level_for_icon(coordinator_data, hour_offset=1)
|
|
if level:
|
|
return PRICE_LEVEL_CASH_ICON_MAPPING.get(level.upper())
|
|
|
|
# For all other price sensors, let entity description handle the icon
|
|
return None
|
|
|
|
|
|
def get_level_sensor_icon(key: str, value: Any) -> str | None:
|
|
"""Get icon for price level sensors."""
|
|
if key not in [
|
|
"current_interval_price_level",
|
|
"next_interval_price_level",
|
|
"previous_interval_price_level",
|
|
"current_hour_price_level",
|
|
"next_hour_price_level",
|
|
"yesterday_price_level",
|
|
"today_price_level",
|
|
"tomorrow_price_level",
|
|
] or not isinstance(value, str):
|
|
return None
|
|
|
|
return PRICE_LEVEL_ICON_MAPPING.get(value.upper())
|
|
|
|
|
|
def get_rating_sensor_icon(key: str, value: Any) -> str | None:
|
|
"""Get icon for price rating sensors."""
|
|
if key not in [
|
|
"current_interval_price_rating",
|
|
"next_interval_price_rating",
|
|
"previous_interval_price_rating",
|
|
"current_hour_price_rating",
|
|
"next_hour_price_rating",
|
|
"yesterday_price_rating",
|
|
"today_price_rating",
|
|
"tomorrow_price_rating",
|
|
] or not isinstance(value, str):
|
|
return None
|
|
|
|
return PRICE_RATING_ICON_MAPPING.get(value.upper())
|
|
|
|
|
|
def get_volatility_sensor_icon(key: str, value: Any) -> str | None:
|
|
"""Get icon for volatility sensors."""
|
|
if not key.endswith("_volatility") or not isinstance(value, str):
|
|
return None
|
|
|
|
return VOLATILITY_ICON_MAPPING.get(value.upper())
|
|
|
|
|
|
def get_binary_sensor_icon(
|
|
key: str,
|
|
*,
|
|
is_on: bool | None,
|
|
has_future_periods_callback: Callable[[], bool] | None = None,
|
|
) -> str | None:
|
|
"""
|
|
Get icon for binary sensors with dynamic state-based icons.
|
|
|
|
Args:
|
|
key: Entity description key
|
|
is_on: Binary sensor state
|
|
has_future_periods_callback: Callback to check if future periods exist
|
|
|
|
Returns:
|
|
Icon string or None if not a binary sensor with dynamic icons
|
|
|
|
"""
|
|
if key not in BINARY_SENSOR_ICON_MAPPING or is_on is None:
|
|
return None
|
|
|
|
if is_on:
|
|
# Sensor is ON - use "on" icon
|
|
return BINARY_SENSOR_ICON_MAPPING[key].get("on")
|
|
|
|
# Sensor is OFF - check if future periods exist
|
|
has_future_periods = has_future_periods_callback() if has_future_periods_callback else False
|
|
|
|
if has_future_periods:
|
|
return BINARY_SENSOR_ICON_MAPPING[key].get("off")
|
|
|
|
return BINARY_SENSOR_ICON_MAPPING[key].get("off_no_future")
|
|
|
|
|
|
def get_price_level_for_icon(
|
|
coordinator_data: dict,
|
|
*,
|
|
interval_offset: int | None = None,
|
|
) -> str | None:
|
|
"""
|
|
Get the price level for icon determination.
|
|
|
|
Supports interval-based lookups (current/next/previous interval).
|
|
|
|
Args:
|
|
coordinator_data: Coordinator data
|
|
interval_offset: Interval offset (0=current, 1=next, -1=previous)
|
|
|
|
Returns:
|
|
Price level string or None if not found
|
|
|
|
"""
|
|
if not coordinator_data or interval_offset is None:
|
|
return None
|
|
|
|
price_info = coordinator_data.get("priceInfo", {})
|
|
now = dt_util.now()
|
|
|
|
# Interval-based lookup
|
|
target_time = now + timedelta(minutes=MINUTES_PER_INTERVAL * interval_offset)
|
|
interval_data = find_price_data_for_interval(price_info, target_time)
|
|
|
|
if not interval_data or "level" not in interval_data:
|
|
return None
|
|
|
|
return interval_data["level"]
|
|
|
|
|
|
def get_rolling_hour_price_level_for_icon(
|
|
coordinator_data: dict,
|
|
*,
|
|
hour_offset: int = 0,
|
|
) -> str | None:
|
|
"""
|
|
Get the aggregated price level for rolling hour icon determination.
|
|
|
|
Uses the same logic as the sensor platform: 5-interval rolling window
|
|
(2 before + center + 2 after) to determine the price level.
|
|
|
|
This ensures icon calculation matches the actual sensor value calculation.
|
|
|
|
Args:
|
|
coordinator_data: Coordinator data
|
|
hour_offset: Hour offset (0=current hour, 1=next hour)
|
|
|
|
Returns:
|
|
Aggregated price level string or None if not found
|
|
|
|
"""
|
|
if not coordinator_data:
|
|
return None
|
|
|
|
price_info = coordinator_data.get("priceInfo", {})
|
|
all_prices = price_info.get("yesterday", []) + price_info.get("today", []) + price_info.get("tomorrow", [])
|
|
|
|
if not all_prices:
|
|
return None
|
|
|
|
# Find center index using the same helper function as the sensor platform
|
|
now = dt_util.now()
|
|
center_idx = find_rolling_hour_center_index(all_prices, now, hour_offset)
|
|
|
|
if center_idx is None:
|
|
return None
|
|
|
|
# Collect data from 5-interval window (-2, -1, 0, +1, +2) - same as sensor platform
|
|
window_data = []
|
|
for offset in range(-2, 3):
|
|
idx = center_idx + offset
|
|
if 0 <= idx < len(all_prices):
|
|
window_data.append(all_prices[idx])
|
|
|
|
if not window_data:
|
|
return None
|
|
|
|
# Use the same aggregation function as the sensor platform
|
|
return aggregate_level_data(window_data)
|