mirror of
https://github.com/jpawlowski/hass.tibber_prices.git
synced 2026-03-29 21:03:40 +00:00
feat(sensors)!: add configurable median/mean display for average sensors
Add user-configurable option to choose between median and arithmetic mean as the displayed value for all 14 average price sensors, with the alternate value exposed as attribute. BREAKING CHANGE: Average sensor default changed from arithmetic mean to median. Users who rely on arithmetic mean behavior may use the price_mean attribue now, or must manually reconfigure via Settings → Devices & Services → Tibber Prices → Configure → General Settings → "Average Sensor Display" → Select "Arithmetic Mean" to get this as sensor state. Affected sensors (14 total): - Daily averages: average_price_today, average_price_tomorrow - 24h windows: trailing_price_average, leading_price_average - Rolling hour: current_hour_average_price, next_hour_average_price - Future forecasts: next_avg_3h, next_avg_6h, next_avg_9h, next_avg_12h Implementation: - All average calculators now return (mean, median) tuples - User preference controls which value appears in sensor state - Alternate value automatically added to attributes - Period statistics (best_price/peak_price) extended with both values Technical changes: - New config option: CONF_AVERAGE_SENSOR_DISPLAY (default: "median") - Calculator functions return tuples: (avg, median) - Attribute builders: add_alternate_average_attribute() helper function - Period statistics: price_avg → price_mean + price_median - Translations: Updated all 5 languages (de, en, nb, nl, sv) - Documentation: AGENTS.md, period-calculation.md, recorder-optimization.md Migration path: Users can switch back to arithmetic mean via: Settings → Integrations → Tibber Prices → Configure → General Settings → "Average Sensor Display" → "Arithmetic Mean" Impact: Median is more resistant to price spikes, providing more stable automation triggers. Statistical analysis from coordinator still uses arithmetic mean (e.g., trailing_avg_24h for rating calculations). Co-developed-with: GitHub Copilot <copilot@github.com>
This commit is contained in:
parent
0f56e80838
commit
51a99980df
28 changed files with 447 additions and 97 deletions
12
AGENTS.md
12
AGENTS.md
|
|
@ -2408,7 +2408,8 @@ attributes = {
|
||||||
"rating_level": ..., # Price rating (LOW, NORMAL, HIGH)
|
"rating_level": ..., # Price rating (LOW, NORMAL, HIGH)
|
||||||
|
|
||||||
# 3. Price statistics (how much does it cost?)
|
# 3. Price statistics (how much does it cost?)
|
||||||
"price_avg": ...,
|
"price_mean": ...,
|
||||||
|
"price_median": ...,
|
||||||
"price_min": ...,
|
"price_min": ...,
|
||||||
"price_max": ...,
|
"price_max": ...,
|
||||||
|
|
||||||
|
|
@ -2608,7 +2609,8 @@ This ensures timestamp is always the first key in the attribute dict, regardless
|
||||||
"start": "2025-11-08T14:00:00+01:00",
|
"start": "2025-11-08T14:00:00+01:00",
|
||||||
"end": "2025-11-08T15:00:00+01:00",
|
"end": "2025-11-08T15:00:00+01:00",
|
||||||
"rating_level": "LOW",
|
"rating_level": "LOW",
|
||||||
"price_avg": 18.5,
|
"price_mean": 18.5,
|
||||||
|
"price_median": 18.3,
|
||||||
"interval_count": 4,
|
"interval_count": 4,
|
||||||
"intervals": [...]
|
"intervals": [...]
|
||||||
}
|
}
|
||||||
|
|
@ -2619,7 +2621,7 @@ This ensures timestamp is always the first key in the attribute dict, regardless
|
||||||
"interval_count": 4,
|
"interval_count": 4,
|
||||||
"rating_level": "LOW",
|
"rating_level": "LOW",
|
||||||
"start": "2025-11-08T14:00:00+01:00",
|
"start": "2025-11-08T14:00:00+01:00",
|
||||||
"price_avg": 18.5,
|
"price_mean": 18.5,
|
||||||
"end": "2025-11-08T15:00:00+01:00"
|
"end": "2025-11-08T15:00:00+01:00"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
@ -2664,8 +2666,8 @@ This ensures timestamp is always the first key in the attribute dict, regardless
|
||||||
|
|
||||||
**Price-Related Attributes:**
|
**Price-Related Attributes:**
|
||||||
|
|
||||||
- Period averages: `period_price_avg` (average across the period)
|
- Period statistics: `price_mean` (arithmetic mean), `price_median` (median value)
|
||||||
- Reference comparisons: `period_price_diff_from_daily_min` (period avg vs daily min)
|
- Reference comparisons: `period_price_diff_from_daily_min` (period mean vs daily min)
|
||||||
- Interval-specific: `interval_price_diff_from_daily_max` (current interval vs daily max)
|
- Interval-specific: `interval_price_diff_from_daily_max` (current interval vs daily max)
|
||||||
|
|
||||||
### Before Adding New Attributes
|
### Before Adding New Attributes
|
||||||
|
|
|
||||||
|
|
@ -168,8 +168,10 @@ def add_decision_attributes(attributes: dict, current_period: dict) -> None:
|
||||||
|
|
||||||
def add_price_attributes(attributes: dict, current_period: dict) -> None:
|
def add_price_attributes(attributes: dict, current_period: dict) -> None:
|
||||||
"""Add price statistics attributes (priority 3)."""
|
"""Add price statistics attributes (priority 3)."""
|
||||||
if "price_avg" in current_period:
|
if "price_mean" in current_period:
|
||||||
attributes["price_avg"] = current_period["price_avg"]
|
attributes["price_mean"] = current_period["price_mean"]
|
||||||
|
if "price_median" in current_period:
|
||||||
|
attributes["price_median"] = current_period["price_median"]
|
||||||
if "price_min" in current_period:
|
if "price_min" in current_period:
|
||||||
attributes["price_min"] = current_period["price_min"]
|
attributes["price_min"] = current_period["price_min"]
|
||||||
if "price_max" in current_period:
|
if "price_max" in current_period:
|
||||||
|
|
@ -234,7 +236,7 @@ def build_final_attributes_simple(
|
||||||
Attributes are ordered following the documented priority:
|
Attributes are ordered following the documented priority:
|
||||||
1. Time information (timestamp, start, end, duration)
|
1. Time information (timestamp, start, end, duration)
|
||||||
2. Core decision attributes (level, rating_level, rating_difference_%)
|
2. Core decision attributes (level, rating_level, rating_difference_%)
|
||||||
3. Price statistics (price_avg, price_min, price_max, price_spread, volatility)
|
3. Price statistics (price_mean, price_median, price_min, price_max, price_spread, volatility)
|
||||||
4. Price differences (period_price_diff_from_daily_min, period_price_diff_from_daily_min_%)
|
4. Price differences (period_price_diff_from_daily_min, period_price_diff_from_daily_min_%)
|
||||||
5. Detail information (period_interval_count, period_position, periods_total, periods_remaining)
|
5. Detail information (period_interval_count, period_position, periods_total, periods_remaining)
|
||||||
6. Relaxation information (relaxation_active, relaxation_level, relaxation_threshold_original_%,
|
6. Relaxation information (relaxation_active, relaxation_level, relaxation_threshold_original_%,
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,7 @@ class TibberPricesBinarySensor(TibberPricesEntity, BinarySensorEntity, RestoreEn
|
||||||
# See: https://developers.home-assistant.io/docs/core/entity/#excluding-state-attributes-from-recorder-history
|
# See: https://developers.home-assistant.io/docs/core/entity/#excluding-state-attributes-from-recorder-history
|
||||||
_unrecorded_attributes = frozenset(
|
_unrecorded_attributes = frozenset(
|
||||||
{
|
{
|
||||||
|
"timestamp",
|
||||||
# Descriptions/Help Text (static, large)
|
# Descriptions/Help Text (static, large)
|
||||||
"description",
|
"description",
|
||||||
"usage_tips",
|
"usage_tips",
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import voluptuous as vol
|
||||||
|
|
||||||
from custom_components.tibber_prices.const import (
|
from custom_components.tibber_prices.const import (
|
||||||
BEST_PRICE_MAX_LEVEL_OPTIONS,
|
BEST_PRICE_MAX_LEVEL_OPTIONS,
|
||||||
|
CONF_AVERAGE_SENSOR_DISPLAY,
|
||||||
CONF_BEST_PRICE_FLEX,
|
CONF_BEST_PRICE_FLEX,
|
||||||
CONF_BEST_PRICE_MAX_LEVEL,
|
CONF_BEST_PRICE_MAX_LEVEL,
|
||||||
CONF_BEST_PRICE_MAX_LEVEL_GAP_COUNT,
|
CONF_BEST_PRICE_MAX_LEVEL_GAP_COUNT,
|
||||||
|
|
@ -38,6 +39,7 @@ from custom_components.tibber_prices.const import (
|
||||||
CONF_VOLATILITY_THRESHOLD_HIGH,
|
CONF_VOLATILITY_THRESHOLD_HIGH,
|
||||||
CONF_VOLATILITY_THRESHOLD_MODERATE,
|
CONF_VOLATILITY_THRESHOLD_MODERATE,
|
||||||
CONF_VOLATILITY_THRESHOLD_VERY_HIGH,
|
CONF_VOLATILITY_THRESHOLD_VERY_HIGH,
|
||||||
|
DEFAULT_AVERAGE_SENSOR_DISPLAY,
|
||||||
DEFAULT_BEST_PRICE_FLEX,
|
DEFAULT_BEST_PRICE_FLEX,
|
||||||
DEFAULT_BEST_PRICE_MAX_LEVEL,
|
DEFAULT_BEST_PRICE_MAX_LEVEL,
|
||||||
DEFAULT_BEST_PRICE_MAX_LEVEL_GAP_COUNT,
|
DEFAULT_BEST_PRICE_MAX_LEVEL_GAP_COUNT,
|
||||||
|
|
@ -205,6 +207,21 @@ def get_options_init_schema(options: Mapping[str, Any]) -> vol.Schema:
|
||||||
CONF_EXTENDED_DESCRIPTIONS,
|
CONF_EXTENDED_DESCRIPTIONS,
|
||||||
default=options.get(CONF_EXTENDED_DESCRIPTIONS, DEFAULT_EXTENDED_DESCRIPTIONS),
|
default=options.get(CONF_EXTENDED_DESCRIPTIONS, DEFAULT_EXTENDED_DESCRIPTIONS),
|
||||||
): BooleanSelector(),
|
): BooleanSelector(),
|
||||||
|
vol.Optional(
|
||||||
|
CONF_AVERAGE_SENSOR_DISPLAY,
|
||||||
|
default=str(
|
||||||
|
options.get(
|
||||||
|
CONF_AVERAGE_SENSOR_DISPLAY,
|
||||||
|
DEFAULT_AVERAGE_SENSOR_DISPLAY,
|
||||||
|
)
|
||||||
|
),
|
||||||
|
): SelectSelector(
|
||||||
|
SelectSelectorConfig(
|
||||||
|
options=["median", "mean"],
|
||||||
|
mode=SelectSelectorMode.DROPDOWN,
|
||||||
|
translation_key="average_sensor_display",
|
||||||
|
),
|
||||||
|
),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,7 @@ CONF_BEST_PRICE_MIN_PERIOD_LENGTH = "best_price_min_period_length"
|
||||||
CONF_PEAK_PRICE_MIN_PERIOD_LENGTH = "peak_price_min_period_length"
|
CONF_PEAK_PRICE_MIN_PERIOD_LENGTH = "peak_price_min_period_length"
|
||||||
CONF_PRICE_RATING_THRESHOLD_LOW = "price_rating_threshold_low"
|
CONF_PRICE_RATING_THRESHOLD_LOW = "price_rating_threshold_low"
|
||||||
CONF_PRICE_RATING_THRESHOLD_HIGH = "price_rating_threshold_high"
|
CONF_PRICE_RATING_THRESHOLD_HIGH = "price_rating_threshold_high"
|
||||||
|
CONF_AVERAGE_SENSOR_DISPLAY = "average_sensor_display" # "median" or "mean"
|
||||||
CONF_PRICE_TREND_THRESHOLD_RISING = "price_trend_threshold_rising"
|
CONF_PRICE_TREND_THRESHOLD_RISING = "price_trend_threshold_rising"
|
||||||
CONF_PRICE_TREND_THRESHOLD_FALLING = "price_trend_threshold_falling"
|
CONF_PRICE_TREND_THRESHOLD_FALLING = "price_trend_threshold_falling"
|
||||||
CONF_VOLATILITY_THRESHOLD_MODERATE = "volatility_threshold_moderate"
|
CONF_VOLATILITY_THRESHOLD_MODERATE = "volatility_threshold_moderate"
|
||||||
|
|
@ -85,6 +86,7 @@ DEFAULT_BEST_PRICE_MIN_PERIOD_LENGTH = 60 # 60 minutes minimum period length fo
|
||||||
DEFAULT_PEAK_PRICE_MIN_PERIOD_LENGTH = 30 # 30 minutes minimum period length for peak price (user-facing, minutes)
|
DEFAULT_PEAK_PRICE_MIN_PERIOD_LENGTH = 30 # 30 minutes minimum period length for peak price (user-facing, minutes)
|
||||||
DEFAULT_PRICE_RATING_THRESHOLD_LOW = -10 # Default rating threshold low percentage
|
DEFAULT_PRICE_RATING_THRESHOLD_LOW = -10 # Default rating threshold low percentage
|
||||||
DEFAULT_PRICE_RATING_THRESHOLD_HIGH = 10 # Default rating threshold high percentage
|
DEFAULT_PRICE_RATING_THRESHOLD_HIGH = 10 # Default rating threshold high percentage
|
||||||
|
DEFAULT_AVERAGE_SENSOR_DISPLAY = "median" # Default: show median in state, mean in attributes
|
||||||
DEFAULT_PRICE_TREND_THRESHOLD_RISING = 3 # Default trend threshold for rising prices (%)
|
DEFAULT_PRICE_TREND_THRESHOLD_RISING = 3 # Default trend threshold for rising prices (%)
|
||||||
DEFAULT_PRICE_TREND_THRESHOLD_FALLING = -3 # Default trend threshold for falling prices (%, negative value)
|
DEFAULT_PRICE_TREND_THRESHOLD_FALLING = -3 # Default trend threshold for falling prices (%, negative value)
|
||||||
# Default volatility thresholds (relative values using coefficient of variation)
|
# Default volatility thresholds (relative values using coefficient of variation)
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ if TYPE_CHECKING:
|
||||||
TibberPricesPeriodStatistics,
|
TibberPricesPeriodStatistics,
|
||||||
TibberPricesThresholdConfig,
|
TibberPricesThresholdConfig,
|
||||||
)
|
)
|
||||||
|
from custom_components.tibber_prices.utils.average import calculate_median
|
||||||
from custom_components.tibber_prices.utils.price import (
|
from custom_components.tibber_prices.utils.price import (
|
||||||
aggregate_period_levels,
|
aggregate_period_levels,
|
||||||
aggregate_period_ratings,
|
aggregate_period_ratings,
|
||||||
|
|
@ -22,7 +23,7 @@ from custom_components.tibber_prices.utils.price import (
|
||||||
|
|
||||||
|
|
||||||
def calculate_period_price_diff(
|
def calculate_period_price_diff(
|
||||||
price_avg: float,
|
price_mean: float,
|
||||||
start_time: datetime,
|
start_time: datetime,
|
||||||
price_context: dict[str, Any],
|
price_context: dict[str, Any],
|
||||||
) -> tuple[float | None, float | None]:
|
) -> tuple[float | None, float | None]:
|
||||||
|
|
@ -47,7 +48,7 @@ def calculate_period_price_diff(
|
||||||
|
|
||||||
# Convert reference price to minor units (ct/øre)
|
# Convert reference price to minor units (ct/øre)
|
||||||
ref_price_minor = round(ref_price * 100, 2)
|
ref_price_minor = round(ref_price * 100, 2)
|
||||||
period_price_diff = round(price_avg - ref_price_minor, 2)
|
period_price_diff = round(price_mean - ref_price_minor, 2)
|
||||||
period_price_diff_pct = None
|
period_price_diff_pct = None
|
||||||
if ref_price_minor != 0:
|
if ref_price_minor != 0:
|
||||||
# CRITICAL: Use abs() for negative prices (same logic as calculate_difference_percentage)
|
# CRITICAL: Use abs() for negative prices (same logic as calculate_difference_percentage)
|
||||||
|
|
@ -90,26 +91,30 @@ def calculate_period_price_statistics(period_price_data: list[dict]) -> dict[str
|
||||||
period_price_data: List of price data dictionaries with "total" field
|
period_price_data: List of price data dictionaries with "total" field
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dictionary with price_avg, price_min, price_max, price_spread (all in minor units: ct/øre)
|
Dictionary with price_mean, price_median, price_min, price_max, price_spread (all in minor units: ct/øre)
|
||||||
|
Note: price_spread is calculated based on price_mean (max - min range as percentage of mean)
|
||||||
|
|
||||||
"""
|
"""
|
||||||
prices_minor = [round(float(p["total"]) * 100, 2) for p in period_price_data]
|
prices_minor = [round(float(p["total"]) * 100, 2) for p in period_price_data]
|
||||||
|
|
||||||
if not prices_minor:
|
if not prices_minor:
|
||||||
return {
|
return {
|
||||||
"price_avg": 0.0,
|
"price_mean": 0.0,
|
||||||
|
"price_median": 0.0,
|
||||||
"price_min": 0.0,
|
"price_min": 0.0,
|
||||||
"price_max": 0.0,
|
"price_max": 0.0,
|
||||||
"price_spread": 0.0,
|
"price_spread": 0.0,
|
||||||
}
|
}
|
||||||
|
|
||||||
price_avg = round(sum(prices_minor) / len(prices_minor), 2)
|
price_mean = round(sum(prices_minor) / len(prices_minor), 2)
|
||||||
|
price_median = round(calculate_median(prices_minor), 2)
|
||||||
price_min = round(min(prices_minor), 2)
|
price_min = round(min(prices_minor), 2)
|
||||||
price_max = round(max(prices_minor), 2)
|
price_max = round(max(prices_minor), 2)
|
||||||
price_spread = round(price_max - price_min, 2)
|
price_spread = round(price_max - price_min, 2)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"price_avg": price_avg,
|
"price_mean": price_mean,
|
||||||
|
"price_median": price_median,
|
||||||
"price_min": price_min,
|
"price_min": price_min,
|
||||||
"price_max": price_max,
|
"price_max": price_max,
|
||||||
"price_spread": price_spread,
|
"price_spread": price_spread,
|
||||||
|
|
@ -147,7 +152,8 @@ def build_period_summary_dict(
|
||||||
"rating_level": stats.aggregated_rating,
|
"rating_level": stats.aggregated_rating,
|
||||||
"rating_difference_%": stats.rating_difference_pct,
|
"rating_difference_%": stats.rating_difference_pct,
|
||||||
# 3. Price statistics (how much does it cost?)
|
# 3. Price statistics (how much does it cost?)
|
||||||
"price_avg": stats.price_avg,
|
"price_mean": stats.price_mean,
|
||||||
|
"price_median": stats.price_median,
|
||||||
"price_min": stats.price_min,
|
"price_min": stats.price_min,
|
||||||
"price_max": stats.price_max,
|
"price_max": stats.price_max,
|
||||||
"price_spread": stats.price_spread,
|
"price_spread": stats.price_spread,
|
||||||
|
|
@ -290,7 +296,7 @@ def extract_period_summaries(
|
||||||
|
|
||||||
# Calculate period price difference from daily reference
|
# Calculate period price difference from daily reference
|
||||||
period_price_diff, period_price_diff_pct = calculate_period_price_diff(
|
period_price_diff, period_price_diff_pct = calculate_period_price_diff(
|
||||||
price_stats["price_avg"], start_time, price_context
|
price_stats["price_mean"], start_time, price_context
|
||||||
)
|
)
|
||||||
|
|
||||||
# Extract prices for volatility calculation (coefficient of variation)
|
# Extract prices for volatility calculation (coefficient of variation)
|
||||||
|
|
@ -324,7 +330,8 @@ def extract_period_summaries(
|
||||||
aggregated_level=aggregated_level,
|
aggregated_level=aggregated_level,
|
||||||
aggregated_rating=aggregated_rating,
|
aggregated_rating=aggregated_rating,
|
||||||
rating_difference_pct=rating_difference_pct,
|
rating_difference_pct=rating_difference_pct,
|
||||||
price_avg=price_stats["price_avg"],
|
price_mean=price_stats["price_mean"],
|
||||||
|
price_median=price_stats["price_median"],
|
||||||
price_min=price_stats["price_min"],
|
price_min=price_stats["price_min"],
|
||||||
price_max=price_stats["price_max"],
|
price_max=price_stats["price_max"],
|
||||||
price_spread=price_stats["price_spread"],
|
price_spread=price_stats["price_spread"],
|
||||||
|
|
|
||||||
|
|
@ -56,7 +56,8 @@ class TibberPricesPeriodStatistics(NamedTuple):
|
||||||
aggregated_level: str | None
|
aggregated_level: str | None
|
||||||
aggregated_rating: str | None
|
aggregated_rating: str | None
|
||||||
rating_difference_pct: float | None
|
rating_difference_pct: float | None
|
||||||
price_avg: float
|
price_mean: float
|
||||||
|
price_median: float
|
||||||
price_min: float
|
price_min: float
|
||||||
price_max: float
|
price_max: float
|
||||||
price_spread: float
|
price_spread: float
|
||||||
|
|
|
||||||
|
|
@ -77,6 +77,8 @@ def build_sensor_attributes(
|
||||||
coordinator: TibberPricesDataUpdateCoordinator,
|
coordinator: TibberPricesDataUpdateCoordinator,
|
||||||
native_value: Any,
|
native_value: Any,
|
||||||
cached_data: dict,
|
cached_data: dict,
|
||||||
|
*,
|
||||||
|
config_entry: TibberPricesConfigEntry,
|
||||||
) -> dict[str, Any] | None:
|
) -> dict[str, Any] | None:
|
||||||
"""
|
"""
|
||||||
Build attributes for a sensor based on its key.
|
Build attributes for a sensor based on its key.
|
||||||
|
|
@ -88,6 +90,7 @@ def build_sensor_attributes(
|
||||||
coordinator: The data update coordinator
|
coordinator: The data update coordinator
|
||||||
native_value: The current native value of the sensor
|
native_value: The current native value of the sensor
|
||||||
cached_data: Dictionary containing cached sensor data
|
cached_data: Dictionary containing cached sensor data
|
||||||
|
config_entry: Config entry for user preferences
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dictionary of attributes or None if no attributes should be added
|
Dictionary of attributes or None if no attributes should be added
|
||||||
|
|
@ -127,6 +130,7 @@ def build_sensor_attributes(
|
||||||
native_value=native_value,
|
native_value=native_value,
|
||||||
cached_data=cached_data,
|
cached_data=cached_data,
|
||||||
time=time,
|
time=time,
|
||||||
|
config_entry=config_entry,
|
||||||
)
|
)
|
||||||
elif key in [
|
elif key in [
|
||||||
"trailing_price_average",
|
"trailing_price_average",
|
||||||
|
|
@ -136,9 +140,23 @@ def build_sensor_attributes(
|
||||||
"leading_price_min",
|
"leading_price_min",
|
||||||
"leading_price_max",
|
"leading_price_max",
|
||||||
]:
|
]:
|
||||||
add_average_price_attributes(attributes=attributes, key=key, coordinator=coordinator, time=time)
|
add_average_price_attributes(
|
||||||
|
attributes=attributes,
|
||||||
|
key=key,
|
||||||
|
coordinator=coordinator,
|
||||||
|
time=time,
|
||||||
|
cached_data=cached_data,
|
||||||
|
config_entry=config_entry,
|
||||||
|
)
|
||||||
elif key.startswith("next_avg_"):
|
elif key.startswith("next_avg_"):
|
||||||
add_next_avg_attributes(attributes=attributes, key=key, coordinator=coordinator, time=time)
|
add_next_avg_attributes(
|
||||||
|
attributes=attributes,
|
||||||
|
key=key,
|
||||||
|
coordinator=coordinator,
|
||||||
|
time=time,
|
||||||
|
cached_data=cached_data,
|
||||||
|
config_entry=config_entry,
|
||||||
|
)
|
||||||
elif any(
|
elif any(
|
||||||
pattern in key
|
pattern in key
|
||||||
for pattern in [
|
for pattern in [
|
||||||
|
|
@ -160,6 +178,7 @@ def build_sensor_attributes(
|
||||||
key=key,
|
key=key,
|
||||||
cached_data=cached_data,
|
cached_data=cached_data,
|
||||||
time=time,
|
time=time,
|
||||||
|
config_entry=config_entry,
|
||||||
)
|
)
|
||||||
elif key == "data_lifecycle_status":
|
elif key == "data_lifecycle_status":
|
||||||
# Lifecycle sensor uses dedicated builder with calculator
|
# Lifecycle sensor uses dedicated builder with calculator
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,9 @@ if TYPE_CHECKING:
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from custom_components.tibber_prices.coordinator.time_service import TibberPricesTimeService
|
from custom_components.tibber_prices.coordinator.time_service import TibberPricesTimeService
|
||||||
|
from custom_components.tibber_prices.data import TibberPricesConfigEntry
|
||||||
|
|
||||||
|
from .helpers import add_alternate_average_attribute
|
||||||
|
|
||||||
|
|
||||||
def _get_day_midnight_timestamp(key: str, *, time: TibberPricesTimeService) -> datetime:
|
def _get_day_midnight_timestamp(key: str, *, time: TibberPricesTimeService) -> datetime:
|
||||||
|
|
@ -83,6 +86,7 @@ def add_statistics_attributes(
|
||||||
cached_data: dict,
|
cached_data: dict,
|
||||||
*,
|
*,
|
||||||
time: TibberPricesTimeService,
|
time: TibberPricesTimeService,
|
||||||
|
config_entry: TibberPricesConfigEntry,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
Add attributes for statistics and rating sensors.
|
Add attributes for statistics and rating sensors.
|
||||||
|
|
@ -92,6 +96,7 @@ def add_statistics_attributes(
|
||||||
key: The sensor entity key
|
key: The sensor entity key
|
||||||
cached_data: Dictionary containing cached sensor data
|
cached_data: Dictionary containing cached sensor data
|
||||||
time: TibberPricesTimeService instance (required)
|
time: TibberPricesTimeService instance (required)
|
||||||
|
config_entry: Config entry for user preferences
|
||||||
|
|
||||||
"""
|
"""
|
||||||
# Data timestamp sensor - shows API fetch time
|
# Data timestamp sensor - shows API fetch time
|
||||||
|
|
@ -126,10 +131,17 @@ def add_statistics_attributes(
|
||||||
attributes["timestamp"] = extreme_starts_at
|
attributes["timestamp"] = extreme_starts_at
|
||||||
return
|
return
|
||||||
|
|
||||||
# Daily average sensors - show midnight to indicate whole day
|
# Daily average sensors - show midnight to indicate whole day + add alternate value
|
||||||
daily_avg_sensors = {"average_price_today", "average_price_tomorrow"}
|
daily_avg_sensors = {"average_price_today", "average_price_tomorrow"}
|
||||||
if key in daily_avg_sensors:
|
if key in daily_avg_sensors:
|
||||||
attributes["timestamp"] = _get_day_midnight_timestamp(key, time=time)
|
attributes["timestamp"] = _get_day_midnight_timestamp(key, time=time)
|
||||||
|
# Add alternate average attribute
|
||||||
|
add_alternate_average_attribute(
|
||||||
|
attributes,
|
||||||
|
cached_data,
|
||||||
|
key, # base_key = key itself ("average_price_today" or "average_price_tomorrow")
|
||||||
|
config_entry=config_entry,
|
||||||
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Daily aggregated level/rating sensors - show midnight to indicate whole day
|
# Daily aggregated level/rating sensors - show midnight to indicate whole day
|
||||||
|
|
|
||||||
|
|
@ -11,17 +11,22 @@ if TYPE_CHECKING:
|
||||||
TibberPricesDataUpdateCoordinator,
|
TibberPricesDataUpdateCoordinator,
|
||||||
)
|
)
|
||||||
from custom_components.tibber_prices.coordinator.time_service import TibberPricesTimeService
|
from custom_components.tibber_prices.coordinator.time_service import TibberPricesTimeService
|
||||||
|
from custom_components.tibber_prices.data import TibberPricesConfigEntry
|
||||||
|
|
||||||
|
from .helpers import add_alternate_average_attribute
|
||||||
|
|
||||||
# Constants
|
# Constants
|
||||||
MAX_FORECAST_INTERVALS = 8 # Show up to 8 future intervals (2 hours with 15-min intervals)
|
MAX_FORECAST_INTERVALS = 8 # Show up to 8 future intervals (2 hours with 15-min intervals)
|
||||||
|
|
||||||
|
|
||||||
def add_next_avg_attributes(
|
def add_next_avg_attributes( # noqa: PLR0913
|
||||||
attributes: dict,
|
attributes: dict,
|
||||||
key: str,
|
key: str,
|
||||||
coordinator: TibberPricesDataUpdateCoordinator,
|
coordinator: TibberPricesDataUpdateCoordinator,
|
||||||
*,
|
*,
|
||||||
time: TibberPricesTimeService,
|
time: TibberPricesTimeService,
|
||||||
|
cached_data: dict | None = None,
|
||||||
|
config_entry: TibberPricesConfigEntry | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
Add attributes for next N hours average price sensors.
|
Add attributes for next N hours average price sensors.
|
||||||
|
|
@ -31,6 +36,8 @@ def add_next_avg_attributes(
|
||||||
key: The sensor entity key
|
key: The sensor entity key
|
||||||
coordinator: The data update coordinator
|
coordinator: The data update coordinator
|
||||||
time: TibberPricesTimeService instance (required)
|
time: TibberPricesTimeService instance (required)
|
||||||
|
cached_data: Optional cached data dictionary for median values
|
||||||
|
config_entry: Optional config entry for user preferences
|
||||||
|
|
||||||
"""
|
"""
|
||||||
# Extract hours from sensor key (e.g., "next_avg_3h" -> 3)
|
# Extract hours from sensor key (e.g., "next_avg_3h" -> 3)
|
||||||
|
|
@ -62,6 +69,16 @@ def add_next_avg_attributes(
|
||||||
attributes["interval_count"] = len(intervals_in_window)
|
attributes["interval_count"] = len(intervals_in_window)
|
||||||
attributes["hours"] = hours
|
attributes["hours"] = hours
|
||||||
|
|
||||||
|
# Add alternate average attribute if available in cached_data
|
||||||
|
if cached_data and config_entry:
|
||||||
|
base_key = f"next_avg_{hours}h"
|
||||||
|
add_alternate_average_attribute(
|
||||||
|
attributes,
|
||||||
|
cached_data,
|
||||||
|
base_key,
|
||||||
|
config_entry=config_entry,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def get_future_prices(
|
def get_future_prices(
|
||||||
coordinator: TibberPricesDataUpdateCoordinator,
|
coordinator: TibberPricesDataUpdateCoordinator,
|
||||||
|
|
|
||||||
52
custom_components/tibber_prices/sensor/attributes/helpers.py
Normal file
52
custom_components/tibber_prices/sensor/attributes/helpers.py
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
"""Helper functions for sensor attributes."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from custom_components.tibber_prices.const import (
|
||||||
|
CONF_AVERAGE_SENSOR_DISPLAY,
|
||||||
|
DEFAULT_AVERAGE_SENSOR_DISPLAY,
|
||||||
|
)
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from custom_components.tibber_prices.data import TibberPricesConfigEntry
|
||||||
|
|
||||||
|
|
||||||
|
def add_alternate_average_attribute(
|
||||||
|
attributes: dict,
|
||||||
|
cached_data: dict,
|
||||||
|
base_key: str,
|
||||||
|
*,
|
||||||
|
config_entry: TibberPricesConfigEntry,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Add the alternate average value (mean or median) as attribute.
|
||||||
|
|
||||||
|
If user selected "median" as state display, adds "price_mean" as attribute.
|
||||||
|
If user selected "mean" as state display, adds "price_median" as attribute.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
attributes: Dictionary to add attribute to
|
||||||
|
cached_data: Cached calculation data containing mean/median values
|
||||||
|
base_key: Base key for cached values (e.g., "average_price_today", "rolling_hour_0")
|
||||||
|
config_entry: Config entry for user preferences
|
||||||
|
|
||||||
|
"""
|
||||||
|
# Get user preference for which value to display in state
|
||||||
|
display_mode = config_entry.options.get(
|
||||||
|
CONF_AVERAGE_SENSOR_DISPLAY,
|
||||||
|
DEFAULT_AVERAGE_SENSOR_DISPLAY,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add the alternate value as attribute
|
||||||
|
if display_mode == "median":
|
||||||
|
# State shows median → add mean as attribute
|
||||||
|
mean_value = cached_data.get(f"{base_key}_mean")
|
||||||
|
if mean_value is not None:
|
||||||
|
attributes["price_mean"] = mean_value
|
||||||
|
else:
|
||||||
|
# State shows mean → add median as attribute
|
||||||
|
median_value = cached_data.get(f"{base_key}_median")
|
||||||
|
if median_value is not None:
|
||||||
|
attributes["price_median"] = median_value
|
||||||
|
|
@ -17,7 +17,9 @@ if TYPE_CHECKING:
|
||||||
TibberPricesDataUpdateCoordinator,
|
TibberPricesDataUpdateCoordinator,
|
||||||
)
|
)
|
||||||
from custom_components.tibber_prices.coordinator.time_service import TibberPricesTimeService
|
from custom_components.tibber_prices.coordinator.time_service import TibberPricesTimeService
|
||||||
|
from custom_components.tibber_prices.data import TibberPricesConfigEntry
|
||||||
|
|
||||||
|
from .helpers import add_alternate_average_attribute
|
||||||
from .metadata import get_current_interval_data
|
from .metadata import get_current_interval_data
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -29,6 +31,7 @@ def add_current_interval_price_attributes( # noqa: PLR0913
|
||||||
cached_data: dict,
|
cached_data: dict,
|
||||||
*,
|
*,
|
||||||
time: TibberPricesTimeService,
|
time: TibberPricesTimeService,
|
||||||
|
config_entry: TibberPricesConfigEntry,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
Add attributes for current interval price sensors.
|
Add attributes for current interval price sensors.
|
||||||
|
|
@ -40,6 +43,7 @@ def add_current_interval_price_attributes( # noqa: PLR0913
|
||||||
native_value: The current native value of the sensor
|
native_value: The current native value of the sensor
|
||||||
cached_data: Dictionary containing cached sensor data
|
cached_data: Dictionary containing cached sensor data
|
||||||
time: TibberPricesTimeService instance (required)
|
time: TibberPricesTimeService instance (required)
|
||||||
|
config_entry: Config entry for user preferences
|
||||||
|
|
||||||
"""
|
"""
|
||||||
now = time.now()
|
now = time.now()
|
||||||
|
|
@ -108,6 +112,15 @@ def add_current_interval_price_attributes( # noqa: PLR0913
|
||||||
if level:
|
if level:
|
||||||
add_icon_color_attribute(attributes, key="price_level", state_value=level)
|
add_icon_color_attribute(attributes, key="price_level", state_value=level)
|
||||||
|
|
||||||
|
# Add alternate average attribute for rolling hour average price sensors
|
||||||
|
base_key = "rolling_hour_0" if key == "current_hour_average_price" else "rolling_hour_1"
|
||||||
|
add_alternate_average_attribute(
|
||||||
|
attributes,
|
||||||
|
cached_data,
|
||||||
|
base_key,
|
||||||
|
config_entry=config_entry,
|
||||||
|
)
|
||||||
|
|
||||||
# Add price level attributes for all level sensors
|
# Add price level attributes for all level sensors
|
||||||
add_level_attributes_for_sensor(
|
add_level_attributes_for_sensor(
|
||||||
attributes=attributes,
|
attributes=attributes,
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,9 @@ if TYPE_CHECKING:
|
||||||
TibberPricesDataUpdateCoordinator,
|
TibberPricesDataUpdateCoordinator,
|
||||||
)
|
)
|
||||||
from custom_components.tibber_prices.coordinator.time_service import TibberPricesTimeService
|
from custom_components.tibber_prices.coordinator.time_service import TibberPricesTimeService
|
||||||
|
from custom_components.tibber_prices.data import TibberPricesConfigEntry
|
||||||
|
|
||||||
|
from .helpers import add_alternate_average_attribute
|
||||||
|
|
||||||
|
|
||||||
def _update_extreme_interval(extreme_interval: dict | None, price_data: dict, key: str) -> dict:
|
def _update_extreme_interval(extreme_interval: dict | None, price_data: dict, key: str) -> dict:
|
||||||
|
|
@ -40,12 +43,14 @@ def _update_extreme_interval(extreme_interval: dict | None, price_data: dict, ke
|
||||||
return price_data if is_new_extreme else extreme_interval
|
return price_data if is_new_extreme else extreme_interval
|
||||||
|
|
||||||
|
|
||||||
def add_average_price_attributes(
|
def add_average_price_attributes( # noqa: PLR0913
|
||||||
attributes: dict,
|
attributes: dict,
|
||||||
key: str,
|
key: str,
|
||||||
coordinator: TibberPricesDataUpdateCoordinator,
|
coordinator: TibberPricesDataUpdateCoordinator,
|
||||||
*,
|
*,
|
||||||
time: TibberPricesTimeService,
|
time: TibberPricesTimeService,
|
||||||
|
cached_data: dict | None = None,
|
||||||
|
config_entry: TibberPricesConfigEntry | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
Add attributes for trailing and leading average/min/max price sensors.
|
Add attributes for trailing and leading average/min/max price sensors.
|
||||||
|
|
@ -55,6 +60,8 @@ def add_average_price_attributes(
|
||||||
key: The sensor entity key
|
key: The sensor entity key
|
||||||
coordinator: The data update coordinator
|
coordinator: The data update coordinator
|
||||||
time: TibberPricesTimeService instance (required)
|
time: TibberPricesTimeService instance (required)
|
||||||
|
cached_data: Optional cached data dictionary for median values
|
||||||
|
config_entry: Optional config entry for user preferences
|
||||||
|
|
||||||
"""
|
"""
|
||||||
# Determine if this is trailing or leading
|
# Determine if this is trailing or leading
|
||||||
|
|
@ -98,3 +105,13 @@ def add_average_price_attributes(
|
||||||
attributes["timestamp"] = intervals_in_window[0].get("startsAt")
|
attributes["timestamp"] = intervals_in_window[0].get("startsAt")
|
||||||
|
|
||||||
attributes["interval_count"] = len(intervals_in_window)
|
attributes["interval_count"] = len(intervals_in_window)
|
||||||
|
|
||||||
|
# Add alternate average attribute for average sensors if available in cached_data
|
||||||
|
if cached_data and config_entry and "average" in key:
|
||||||
|
base_key = key.replace("_average", "")
|
||||||
|
add_alternate_average_attribute(
|
||||||
|
attributes,
|
||||||
|
cached_data,
|
||||||
|
base_key,
|
||||||
|
config_entry=config_entry,
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -49,8 +49,8 @@ class TibberPricesDailyStatCalculator(TibberPricesBaseCalculator):
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
day: str = "today",
|
day: str = "today",
|
||||||
stat_func: Callable[[list[float]], float],
|
stat_func: Callable[[list[float]], float] | Callable[[list[float]], tuple[float, float | None]],
|
||||||
) -> float | None:
|
) -> float | tuple[float, float | None] | None:
|
||||||
"""
|
"""
|
||||||
Unified method for daily statistics (min/max/avg within calendar day).
|
Unified method for daily statistics (min/max/avg within calendar day).
|
||||||
|
|
||||||
|
|
@ -59,10 +59,12 @@ class TibberPricesDailyStatCalculator(TibberPricesBaseCalculator):
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
day: "today" or "tomorrow" - which calendar day to calculate for.
|
day: "today" or "tomorrow" - which calendar day to calculate for.
|
||||||
stat_func: Statistical function (min, max, or lambda for avg).
|
stat_func: Statistical function (min, max, or lambda for avg/median).
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Price value in minor currency units (cents/øre), or None if unavailable.
|
Price value in minor currency units (cents/øre), or None if unavailable.
|
||||||
|
For average functions: tuple of (avg, median) where median may be None.
|
||||||
|
For min/max functions: single float value.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
if not self.has_data():
|
if not self.has_data():
|
||||||
|
|
@ -97,7 +99,21 @@ class TibberPricesDailyStatCalculator(TibberPricesBaseCalculator):
|
||||||
|
|
||||||
# Find the extreme value and store its interval for later use in attributes
|
# Find the extreme value and store its interval for later use in attributes
|
||||||
prices = [pi["price"] for pi in price_intervals]
|
prices = [pi["price"] for pi in price_intervals]
|
||||||
value = stat_func(prices)
|
result = stat_func(prices)
|
||||||
|
|
||||||
|
# Check if result is a tuple (avg, median) from average functions
|
||||||
|
if isinstance(result, tuple):
|
||||||
|
value, median = result
|
||||||
|
# Store the interval (for avg, use first interval as reference)
|
||||||
|
if price_intervals:
|
||||||
|
self._last_extreme_interval = price_intervals[0]["interval"]
|
||||||
|
# Convert both to minor currency units
|
||||||
|
avg_result = round(get_price_value(value, in_euro=False), 2)
|
||||||
|
median_result = round(get_price_value(median, in_euro=False), 2) if median is not None else None
|
||||||
|
return avg_result, median_result
|
||||||
|
|
||||||
|
# Single value result (min/max functions)
|
||||||
|
value = result
|
||||||
|
|
||||||
# Store the interval with the extreme price for use in attributes
|
# Store the interval with the extreme price for use in attributes
|
||||||
for pi in price_intervals:
|
for pi in price_intervals:
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,7 @@ class TibberPricesRollingHourCalculator(TibberPricesBaseCalculator):
|
||||||
*,
|
*,
|
||||||
hour_offset: int = 0,
|
hour_offset: int = 0,
|
||||||
value_type: str = "price",
|
value_type: str = "price",
|
||||||
) -> str | float | None:
|
) -> str | float | tuple[float | None, float | None] | None:
|
||||||
"""
|
"""
|
||||||
Unified method to get aggregated values from 5-interval rolling window.
|
Unified method to get aggregated values from 5-interval rolling window.
|
||||||
|
|
||||||
|
|
@ -44,7 +44,7 @@ class TibberPricesRollingHourCalculator(TibberPricesBaseCalculator):
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Aggregated value based on type:
|
Aggregated value based on type:
|
||||||
- "price": float (average price in minor currency units)
|
- "price": float or tuple[float, float | None] (avg, median)
|
||||||
- "level": str (aggregated level: "very_cheap", "cheap", etc.)
|
- "level": str (aggregated level: "very_cheap", "cheap", etc.)
|
||||||
- "rating": str (aggregated rating: "low", "normal", "high")
|
- "rating": str (aggregated rating: "low", "normal", "high")
|
||||||
|
|
||||||
|
|
@ -81,7 +81,7 @@ class TibberPricesRollingHourCalculator(TibberPricesBaseCalculator):
|
||||||
self,
|
self,
|
||||||
window_data: list[dict],
|
window_data: list[dict],
|
||||||
value_type: str,
|
value_type: str,
|
||||||
) -> str | float | None:
|
) -> str | float | tuple[float | None, float | None] | None:
|
||||||
"""
|
"""
|
||||||
Aggregate data from multiple intervals based on value type.
|
Aggregate data from multiple intervals based on value type.
|
||||||
|
|
||||||
|
|
@ -90,7 +90,10 @@ class TibberPricesRollingHourCalculator(TibberPricesBaseCalculator):
|
||||||
value_type: "price" | "level" | "rating".
|
value_type: "price" | "level" | "rating".
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Aggregated value based on type.
|
Aggregated value based on type:
|
||||||
|
- "price": tuple[float, float | None] (avg, median)
|
||||||
|
- "level": str
|
||||||
|
- "rating": str
|
||||||
|
|
||||||
"""
|
"""
|
||||||
# Get thresholds from config for rating aggregation
|
# Get thresholds from config for rating aggregation
|
||||||
|
|
@ -103,9 +106,12 @@ class TibberPricesRollingHourCalculator(TibberPricesBaseCalculator):
|
||||||
DEFAULT_PRICE_RATING_THRESHOLD_HIGH,
|
DEFAULT_PRICE_RATING_THRESHOLD_HIGH,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Map value types to aggregation functions
|
# Handle price aggregation - return tuple directly
|
||||||
|
if value_type == "price":
|
||||||
|
return aggregate_price_data(window_data)
|
||||||
|
|
||||||
|
# Map other value types to aggregation functions
|
||||||
aggregators = {
|
aggregators = {
|
||||||
"price": lambda data: aggregate_price_data(data),
|
|
||||||
"level": lambda data: aggregate_level_data(data),
|
"level": lambda data: aggregate_level_data(data),
|
||||||
"rating": lambda data: aggregate_rating_data(data, threshold_low, threshold_high),
|
"rating": lambda data: aggregate_rating_data(data, threshold_low, threshold_high),
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -97,7 +97,7 @@ class TibberPricesTrendCalculator(TibberPricesBaseCalculator):
|
||||||
next_interval_start = time.get_next_interval_start()
|
next_interval_start = time.get_next_interval_start()
|
||||||
|
|
||||||
# Get future average price
|
# Get future average price
|
||||||
future_avg = calculate_next_n_hours_avg(self.coordinator.data, hours, time=self.coordinator.time)
|
future_avg, _ = calculate_next_n_hours_avg(self.coordinator.data, hours, time=self.coordinator.time)
|
||||||
if future_avg is None:
|
if future_avg is None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ class TibberPricesWindow24hCalculator(TibberPricesBaseCalculator):
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
stat_func: Callable,
|
stat_func: Callable,
|
||||||
) -> float | None:
|
) -> float | tuple[float, float | None] | None:
|
||||||
"""
|
"""
|
||||||
Unified method for 24-hour sliding window statistics.
|
Unified method for 24-hour sliding window statistics.
|
||||||
|
|
||||||
|
|
@ -37,13 +37,27 @@ class TibberPricesWindow24hCalculator(TibberPricesBaseCalculator):
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Price value in minor currency units (cents/øre), or None if unavailable.
|
Price value in minor currency units (cents/øre), or None if unavailable.
|
||||||
|
For average functions: tuple of (avg, median) where median may be None.
|
||||||
|
For min/max functions: single float value.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
if not self.has_data():
|
if not self.has_data():
|
||||||
return None
|
return None
|
||||||
|
|
||||||
value = stat_func(self.coordinator_data, time=self.coordinator.time)
|
result = stat_func(self.coordinator_data, time=self.coordinator.time)
|
||||||
|
|
||||||
|
# Check if result is a tuple (avg, median) from average functions
|
||||||
|
if isinstance(result, tuple):
|
||||||
|
value, median = result
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
# Return both values converted to minor currency units
|
||||||
|
avg_result = round(get_price_value(value, in_euro=False), 2)
|
||||||
|
median_result = round(get_price_value(median, in_euro=False), 2) if median is not None else None
|
||||||
|
return avg_result, median_result
|
||||||
|
|
||||||
|
# Single value result (min/max functions)
|
||||||
|
value = result
|
||||||
if value is None:
|
if value is None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,8 +9,10 @@ from custom_components.tibber_prices.binary_sensor.attributes import (
|
||||||
get_price_intervals_attributes,
|
get_price_intervals_attributes,
|
||||||
)
|
)
|
||||||
from custom_components.tibber_prices.const import (
|
from custom_components.tibber_prices.const import (
|
||||||
|
CONF_AVERAGE_SENSOR_DISPLAY,
|
||||||
CONF_PRICE_RATING_THRESHOLD_HIGH,
|
CONF_PRICE_RATING_THRESHOLD_HIGH,
|
||||||
CONF_PRICE_RATING_THRESHOLD_LOW,
|
CONF_PRICE_RATING_THRESHOLD_LOW,
|
||||||
|
DEFAULT_AVERAGE_SENSOR_DISPLAY,
|
||||||
DEFAULT_PRICE_RATING_THRESHOLD_HIGH,
|
DEFAULT_PRICE_RATING_THRESHOLD_HIGH,
|
||||||
DEFAULT_PRICE_RATING_THRESHOLD_LOW,
|
DEFAULT_PRICE_RATING_THRESHOLD_LOW,
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
|
|
@ -99,6 +101,7 @@ class TibberPricesSensor(TibberPricesEntity, RestoreSensor):
|
||||||
# See: https://developers.home-assistant.io/docs/core/entity/#excluding-state-attributes-from-recorder-history
|
# See: https://developers.home-assistant.io/docs/core/entity/#excluding-state-attributes-from-recorder-history
|
||||||
_unrecorded_attributes = frozenset(
|
_unrecorded_attributes = frozenset(
|
||||||
{
|
{
|
||||||
|
"timestamp",
|
||||||
# Descriptions/Help Text (static, large)
|
# Descriptions/Help Text (static, large)
|
||||||
"description",
|
"description",
|
||||||
"usage_tips",
|
"usage_tips",
|
||||||
|
|
@ -158,6 +161,8 @@ class TibberPricesSensor(TibberPricesEntity, RestoreSensor):
|
||||||
self.entity_description = entity_description
|
self.entity_description = entity_description
|
||||||
self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{entity_description.key}"
|
self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{entity_description.key}"
|
||||||
self._attr_has_entity_name = True
|
self._attr_has_entity_name = True
|
||||||
|
# Cached data for attributes (e.g., median values)
|
||||||
|
self.cached_data: dict[str, Any] = {}
|
||||||
# Instantiate calculators
|
# Instantiate calculators
|
||||||
self._metadata_calculator = TibberPricesMetadataCalculator(coordinator)
|
self._metadata_calculator = TibberPricesMetadataCalculator(coordinator)
|
||||||
self._volatility_calculator = TibberPricesVolatilityCalculator(coordinator)
|
self._volatility_calculator = TibberPricesVolatilityCalculator(coordinator)
|
||||||
|
|
@ -376,7 +381,15 @@ class TibberPricesSensor(TibberPricesEntity, RestoreSensor):
|
||||||
if not window_data:
|
if not window_data:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
return self._rolling_hour_calculator.aggregate_window_data(window_data, value_type)
|
result = self._rolling_hour_calculator.aggregate_window_data(window_data, value_type)
|
||||||
|
# For price type, aggregate_window_data returns (avg, median)
|
||||||
|
if isinstance(result, tuple):
|
||||||
|
avg, median = result
|
||||||
|
# Cache median for attributes
|
||||||
|
if median is not None:
|
||||||
|
self.cached_data[f"{self.entity_description.key}_median"] = median
|
||||||
|
return avg
|
||||||
|
return result
|
||||||
|
|
||||||
# ========================================================================
|
# ========================================================================
|
||||||
# INTERVAL-BASED VALUE METHODS
|
# INTERVAL-BASED VALUE METHODS
|
||||||
|
|
@ -563,10 +576,14 @@ class TibberPricesSensor(TibberPricesEntity, RestoreSensor):
|
||||||
Average price in minor currency units (e.g., cents), or None if unavailable
|
Average price in minor currency units (e.g., cents), or None if unavailable
|
||||||
|
|
||||||
"""
|
"""
|
||||||
avg_price = calculate_next_n_hours_avg(self.coordinator.data, hours, time=self.coordinator.time)
|
avg_price, median_price = calculate_next_n_hours_avg(self.coordinator.data, hours, time=self.coordinator.time)
|
||||||
if avg_price is None:
|
if avg_price is None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
# Store median for attributes
|
||||||
|
if median_price is not None:
|
||||||
|
self.cached_data[f"next_avg_{hours}h_median"] = round(median_price * 100, 2)
|
||||||
|
|
||||||
# Convert from major to minor currency units (e.g., EUR to cents)
|
# Convert from major to minor currency units (e.g., EUR to cents)
|
||||||
return round(avg_price * 100, 2)
|
return round(avg_price * 100, 2)
|
||||||
|
|
||||||
|
|
@ -773,7 +790,7 @@ class TibberPricesSensor(TibberPricesEntity, RestoreSensor):
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def native_value(self) -> float | str | datetime | None:
|
def native_value(self) -> float | str | datetime | None: # noqa: PLR0912
|
||||||
"""Return the native value of the sensor."""
|
"""Return the native value of the sensor."""
|
||||||
try:
|
try:
|
||||||
if not self.coordinator.data or not self._value_getter:
|
if not self.coordinator.data or not self._value_getter:
|
||||||
|
|
@ -781,7 +798,8 @@ class TibberPricesSensor(TibberPricesEntity, RestoreSensor):
|
||||||
# For price_level, ensure we return the translated value as state
|
# For price_level, ensure we return the translated value as state
|
||||||
if self.entity_description.key == "current_interval_price_level":
|
if self.entity_description.key == "current_interval_price_level":
|
||||||
return self._interval_calculator.get_price_level_value()
|
return self._interval_calculator.get_price_level_value()
|
||||||
return self._value_getter()
|
|
||||||
|
result = self._value_getter()
|
||||||
except (KeyError, ValueError, TypeError) as ex:
|
except (KeyError, ValueError, TypeError) as ex:
|
||||||
self.coordinator.logger.exception(
|
self.coordinator.logger.exception(
|
||||||
"Error getting sensor value",
|
"Error getting sensor value",
|
||||||
|
|
@ -791,6 +809,48 @@ class TibberPricesSensor(TibberPricesEntity, RestoreSensor):
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
|
else:
|
||||||
|
# Handle tuple results (average + median) from calculators
|
||||||
|
if isinstance(result, tuple):
|
||||||
|
avg, median = result
|
||||||
|
# Get user preference for state display
|
||||||
|
display_pref = self.coordinator.config_entry.options.get(
|
||||||
|
CONF_AVERAGE_SENSOR_DISPLAY,
|
||||||
|
DEFAULT_AVERAGE_SENSOR_DISPLAY,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Cache BOTH values for attribute builders to use
|
||||||
|
key = self.entity_description.key
|
||||||
|
if "average_price_today" in key:
|
||||||
|
self.cached_data["average_price_today_mean"] = avg
|
||||||
|
self.cached_data["average_price_today_median"] = median
|
||||||
|
elif "average_price_tomorrow" in key:
|
||||||
|
self.cached_data["average_price_tomorrow_mean"] = avg
|
||||||
|
self.cached_data["average_price_tomorrow_median"] = median
|
||||||
|
elif "trailing_price_average" in key:
|
||||||
|
self.cached_data["trailing_price_mean"] = avg
|
||||||
|
self.cached_data["trailing_price_median"] = median
|
||||||
|
elif "leading_price_average" in key:
|
||||||
|
self.cached_data["leading_price_mean"] = avg
|
||||||
|
self.cached_data["leading_price_median"] = median
|
||||||
|
elif "current_hour_average_price" in key:
|
||||||
|
self.cached_data["rolling_hour_0_mean"] = avg
|
||||||
|
self.cached_data["rolling_hour_0_median"] = median
|
||||||
|
elif "next_hour_average_price" in key:
|
||||||
|
self.cached_data["rolling_hour_1_mean"] = avg
|
||||||
|
self.cached_data["rolling_hour_1_median"] = median
|
||||||
|
elif key.startswith("next_avg_"):
|
||||||
|
# Extract hours from key (e.g., "next_avg_3h" -> "3")
|
||||||
|
hours = key.split("_")[-1].replace("h", "")
|
||||||
|
self.cached_data[f"next_avg_{hours}h_mean"] = avg
|
||||||
|
self.cached_data[f"next_avg_{hours}h_median"] = median
|
||||||
|
|
||||||
|
# Return the value chosen for state display
|
||||||
|
if display_pref == "median":
|
||||||
|
return median
|
||||||
|
return avg # "mean"
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def native_unit_of_measurement(self) -> str | None:
|
def native_unit_of_measurement(self) -> str | None:
|
||||||
|
|
@ -933,7 +993,12 @@ class TibberPricesSensor(TibberPricesEntity, RestoreSensor):
|
||||||
return self._get_chart_metadata_attributes()
|
return self._get_chart_metadata_attributes()
|
||||||
|
|
||||||
# Prepare cached data that attribute builders might need
|
# Prepare cached data that attribute builders might need
|
||||||
cached_data = {
|
# Start with all mean/median values from self.cached_data
|
||||||
|
cached_data = {k: v for k, v in self.cached_data.items() if "_mean" in k or "_median" in k}
|
||||||
|
|
||||||
|
# Add special calculator results
|
||||||
|
cached_data.update(
|
||||||
|
{
|
||||||
"trend_attributes": self._trend_calculator.get_trend_attributes(),
|
"trend_attributes": self._trend_calculator.get_trend_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(),
|
||||||
|
|
@ -946,6 +1011,7 @@ class TibberPricesSensor(TibberPricesEntity, RestoreSensor):
|
||||||
"rolling_hour_level": self._get_rolling_hour_level_for_cached_data(key),
|
"rolling_hour_level": self._get_rolling_hour_level_for_cached_data(key),
|
||||||
"lifecycle_calculator": self._lifecycle_calculator, # For lifecycle sensor attributes
|
"lifecycle_calculator": self._lifecycle_calculator, # For lifecycle sensor attributes
|
||||||
}
|
}
|
||||||
|
)
|
||||||
|
|
||||||
# Use the centralized attribute builder
|
# Use the centralized attribute builder
|
||||||
return build_sensor_attributes(
|
return build_sensor_attributes(
|
||||||
|
|
@ -953,6 +1019,7 @@ class TibberPricesSensor(TibberPricesEntity, RestoreSensor):
|
||||||
coordinator=self.coordinator,
|
coordinator=self.coordinator,
|
||||||
native_value=self.native_value,
|
native_value=self.native_value,
|
||||||
cached_data=cached_data,
|
cached_data=cached_data,
|
||||||
|
config_entry=self.coordinator.config_entry,
|
||||||
)
|
)
|
||||||
|
|
||||||
def _get_rolling_hour_level_for_cached_data(self, key: str) -> str | None:
|
def _get_rolling_hour_level_for_cached_data(self, key: str) -> str | None:
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ if TYPE_CHECKING:
|
||||||
|
|
||||||
from custom_components.tibber_prices.coordinator.helpers import get_intervals_for_day_offsets
|
from custom_components.tibber_prices.coordinator.helpers import get_intervals_for_day_offsets
|
||||||
from custom_components.tibber_prices.entity_utils.helpers import get_price_value
|
from custom_components.tibber_prices.entity_utils.helpers import get_price_value
|
||||||
|
from custom_components.tibber_prices.utils.average import calculate_median
|
||||||
from custom_components.tibber_prices.utils.price import (
|
from custom_components.tibber_prices.utils.price import (
|
||||||
aggregate_price_levels,
|
aggregate_price_levels,
|
||||||
aggregate_price_rating,
|
aggregate_price_rating,
|
||||||
|
|
@ -35,22 +36,26 @@ if TYPE_CHECKING:
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
|
|
||||||
|
|
||||||
def aggregate_price_data(window_data: list[dict]) -> float | None:
|
def aggregate_price_data(window_data: list[dict]) -> tuple[float | None, float | None]:
|
||||||
"""
|
"""
|
||||||
Calculate average price from window data.
|
Calculate average and median price from window data.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
window_data: List of price interval dictionaries with 'total' key
|
window_data: List of price interval dictionaries with 'total' key
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Average price in minor currency units (cents/øre), or None if no prices
|
Tuple of (average price, median price) in minor currency units (cents/øre),
|
||||||
|
or (None, None) if no prices
|
||||||
|
|
||||||
"""
|
"""
|
||||||
prices = [float(i["total"]) for i in window_data if "total" in i]
|
prices = [float(i["total"]) for i in window_data if "total" in i]
|
||||||
if not prices:
|
if not prices:
|
||||||
return None
|
return None, None
|
||||||
|
# Calculate both average and median
|
||||||
|
avg = sum(prices) / len(prices)
|
||||||
|
median = calculate_median(prices)
|
||||||
# Return in minor currency units (cents/øre)
|
# Return in minor currency units (cents/øre)
|
||||||
return round((sum(prices) / len(prices)) * 100, 2)
|
return round(avg * 100, 2), round(median * 100, 2) if median is not None else None
|
||||||
|
|
||||||
|
|
||||||
def aggregate_level_data(window_data: list[dict]) -> str | None:
|
def aggregate_level_data(window_data: list[dict]) -> str | None:
|
||||||
|
|
@ -119,7 +124,7 @@ def aggregate_window_data(
|
||||||
"""
|
"""
|
||||||
# Map value types to aggregation functions
|
# Map value types to aggregation functions
|
||||||
aggregators: dict[str, Callable] = {
|
aggregators: dict[str, Callable] = {
|
||||||
"price": lambda data: aggregate_price_data(data),
|
"price": lambda data: aggregate_price_data(data)[0], # Use only average from tuple
|
||||||
"level": lambda data: aggregate_level_data(data),
|
"level": lambda data: aggregate_level_data(data),
|
||||||
"rating": lambda data: aggregate_rating_data(data, threshold_low, threshold_high),
|
"rating": lambda data: aggregate_rating_data(data, threshold_low, threshold_high),
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ from custom_components.tibber_prices.utils.average import (
|
||||||
calculate_current_trailing_avg,
|
calculate_current_trailing_avg,
|
||||||
calculate_current_trailing_max,
|
calculate_current_trailing_max,
|
||||||
calculate_current_trailing_min,
|
calculate_current_trailing_min,
|
||||||
|
calculate_median,
|
||||||
)
|
)
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
|
|
@ -130,14 +131,14 @@ def get_value_getter_mapping( # noqa: PLR0913 - needs all calculators as parame
|
||||||
"highest_price_today": lambda: daily_stat_calculator.get_daily_stat_value(day="today", stat_func=max),
|
"highest_price_today": lambda: daily_stat_calculator.get_daily_stat_value(day="today", stat_func=max),
|
||||||
"average_price_today": lambda: daily_stat_calculator.get_daily_stat_value(
|
"average_price_today": lambda: daily_stat_calculator.get_daily_stat_value(
|
||||||
day="today",
|
day="today",
|
||||||
stat_func=lambda prices: sum(prices) / len(prices),
|
stat_func=lambda prices: (sum(prices) / len(prices), calculate_median(prices)),
|
||||||
),
|
),
|
||||||
# Tomorrow statistics sensors
|
# Tomorrow statistics sensors
|
||||||
"lowest_price_tomorrow": lambda: daily_stat_calculator.get_daily_stat_value(day="tomorrow", stat_func=min),
|
"lowest_price_tomorrow": lambda: daily_stat_calculator.get_daily_stat_value(day="tomorrow", stat_func=min),
|
||||||
"highest_price_tomorrow": lambda: daily_stat_calculator.get_daily_stat_value(day="tomorrow", stat_func=max),
|
"highest_price_tomorrow": lambda: daily_stat_calculator.get_daily_stat_value(day="tomorrow", stat_func=max),
|
||||||
"average_price_tomorrow": lambda: daily_stat_calculator.get_daily_stat_value(
|
"average_price_tomorrow": lambda: daily_stat_calculator.get_daily_stat_value(
|
||||||
day="tomorrow",
|
day="tomorrow",
|
||||||
stat_func=lambda prices: sum(prices) / len(prices),
|
stat_func=lambda prices: (sum(prices) / len(prices), calculate_median(prices)),
|
||||||
),
|
),
|
||||||
# Daily aggregated level sensors
|
# Daily aggregated level sensors
|
||||||
"yesterday_price_level": lambda: daily_stat_calculator.get_daily_aggregated_value(
|
"yesterday_price_level": lambda: daily_stat_calculator.get_daily_aggregated_value(
|
||||||
|
|
|
||||||
|
|
@ -135,10 +135,12 @@
|
||||||
"title": "⚙️ Allgemeine Einstellungen",
|
"title": "⚙️ Allgemeine Einstellungen",
|
||||||
"description": "_{step_progress}_\n\n**Konfiguriere allgemeine Einstellungen für Tibber-Preisinformationen und -bewertungen.**\n\n---\n\n**Benutzer:** {user_login}",
|
"description": "_{step_progress}_\n\n**Konfiguriere allgemeine Einstellungen für Tibber-Preisinformationen und -bewertungen.**\n\n---\n\n**Benutzer:** {user_login}",
|
||||||
"data": {
|
"data": {
|
||||||
"extended_descriptions": "Erweiterte Beschreibungen"
|
"extended_descriptions": "Erweiterte Beschreibungen",
|
||||||
|
"average_sensor_display": "Durchschnittsensor-Anzeige"
|
||||||
},
|
},
|
||||||
"data_description": {
|
"data_description": {
|
||||||
"extended_descriptions": "Steuert, ob Entitätsattribute ausführliche Erklärungen und Nutzungstipps enthalten.\n\n• Deaktiviert (Standard): Nur kurze Beschreibung\n• Aktiviert: Ausführliche Erklärung + praktische Nutzungsbeispiele\n\nBeispiel:\nDeaktiviert = 1 Attribut\nAktiviert = 2 zusätzliche Attribute"
|
"extended_descriptions": "Steuert, ob Entitätsattribute ausführliche Erklärungen und Nutzungstipps enthalten.\n\n• Deaktiviert (Standard): Nur kurze Beschreibung\n• Aktiviert: Ausführliche Erklärung + praktische Nutzungsbeispiele\n\nBeispiel:\nDeaktiviert = 1 Attribut\nAktiviert = 2 zusätzliche Attribute",
|
||||||
|
"average_sensor_display": "Wähle aus, welcher statistische Wert im Sensorstatus für Durchschnitts-Preissensoren angezeigt wird. Der andere Wert wird als Attribut angezeigt. Der Median ist resistenter gegen Extremwerte, während das arithmetische Mittel dem traditionellen Durchschnitt entspricht. Standard: Median"
|
||||||
},
|
},
|
||||||
"submit": "Weiter →"
|
"submit": "Weiter →"
|
||||||
},
|
},
|
||||||
|
|
@ -151,11 +153,13 @@
|
||||||
"description": "Definiere die Einstufungen für die Preisbewertung.",
|
"description": "Definiere die Einstufungen für die Preisbewertung.",
|
||||||
"data": {
|
"data": {
|
||||||
"price_rating_threshold_low": "Niedrig-Schwelle",
|
"price_rating_threshold_low": "Niedrig-Schwelle",
|
||||||
"price_rating_threshold_high": "Hoch-Schwelle"
|
"price_rating_threshold_high": "Hoch-Schwelle",
|
||||||
|
"average_sensor_display": "Durchschnitts-Sensor Anzeige"
|
||||||
},
|
},
|
||||||
"data_description": {
|
"data_description": {
|
||||||
"price_rating_threshold_low": "Prozentwert, um wie viel der aktuelle Preis unter dem nachlaufenden 24-Stunden-Durchschnitt liegen muss, damit er als 'niedrig' bewertet wird. Beispiel: 5 bedeutet mindestens 5% unter Durchschnitt. Sensoren mit dieser Bewertung zeigen günstige Zeitfenster an. Standard: 5%",
|
"price_rating_threshold_low": "Prozentwert, um wie viel der aktuelle Preis unter dem nachlaufenden 24-Stunden-Durchschnitt liegen muss, damit er als 'niedrig' bewertet wird. Beispiel: 5 bedeutet mindestens 5% unter Durchschnitt. Sensoren mit dieser Bewertung zeigen günstige Zeitfenster an. Standard: 5%",
|
||||||
"price_rating_threshold_high": "Prozentwert, um wie viel der aktuelle Preis über dem nachlaufenden 24-Stunden-Durchschnitt liegen muss, damit er als 'hoch' bewertet wird. Beispiel: 10 bedeutet mindestens 10% über Durchschnitt. Sensoren mit dieser Bewertung warnen vor teuren Zeitfenstern. Standard: 10%"
|
"price_rating_threshold_high": "Prozentwert, um wie viel der aktuelle Preis über dem nachlaufenden 24-Stunden-Durchschnitt liegen muss, damit er als 'hoch' bewertet wird. Beispiel: 10 bedeutet mindestens 10% über Durchschnitt. Sensoren mit dieser Bewertung warnen vor teuren Zeitfenstern. Standard: 10%",
|
||||||
|
"average_sensor_display": "Wähle, welches statistische Maß im Sensor-Status für Durchschnittspreissensoren angezeigt werden soll. Der andere Wert wird als Attribut angezeigt. Der Median ist widerstandsfähiger gegen Extremwerte, während das arithmetische Mittel den traditionellen Durchschnitt darstellt. Standard: Median"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -1118,6 +1122,12 @@
|
||||||
"expensive": "Teuer",
|
"expensive": "Teuer",
|
||||||
"very_expensive": "Sehr teuer"
|
"very_expensive": "Sehr teuer"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"average_sensor_display": {
|
||||||
|
"options": {
|
||||||
|
"median": "Median",
|
||||||
|
"mean": "Arithmetisches Mittel"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"title": "Tibber Preisinformationen & Bewertungen"
|
"title": "Tibber Preisinformationen & Bewertungen"
|
||||||
|
|
|
||||||
|
|
@ -135,10 +135,12 @@
|
||||||
"title": "⚙️ General Settings",
|
"title": "⚙️ General Settings",
|
||||||
"description": "_{step_progress}_\n\n**Configure general settings for Tibber Price Information & Ratings.**\n\n---\n\n**User:** {user_login}",
|
"description": "_{step_progress}_\n\n**Configure general settings for Tibber Price Information & Ratings.**\n\n---\n\n**User:** {user_login}",
|
||||||
"data": {
|
"data": {
|
||||||
"extended_descriptions": "Extended Descriptions"
|
"extended_descriptions": "Extended Descriptions",
|
||||||
|
"average_sensor_display": "Average Sensor Display"
|
||||||
},
|
},
|
||||||
"data_description": {
|
"data_description": {
|
||||||
"extended_descriptions": "Controls whether entity attributes include detailed explanations and usage tips.\n\n• Disabled (default): Brief description only\n• Enabled: Detailed explanation + practical usage examples\n\nExample:\nDisabled = 1 attribute\nEnabled = 2 additional attributes"
|
"extended_descriptions": "Controls whether entity attributes include detailed explanations and usage tips.\n\n• Disabled (default): Brief description only\n• Enabled: Detailed explanation + practical usage examples\n\nExample:\nDisabled = 1 attribute\nEnabled = 2 additional attributes",
|
||||||
|
"average_sensor_display": "Choose which statistical measure to display in the sensor state for average price sensors. The other value will be shown as an attribute. Median is more resistant to extreme values, while arithmetic mean represents the traditional average. Default: Median"
|
||||||
},
|
},
|
||||||
"submit": "Continue →"
|
"submit": "Continue →"
|
||||||
},
|
},
|
||||||
|
|
@ -1118,6 +1120,12 @@
|
||||||
"expensive": "Expensive",
|
"expensive": "Expensive",
|
||||||
"very_expensive": "Very expensive"
|
"very_expensive": "Very expensive"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"average_sensor_display": {
|
||||||
|
"options": {
|
||||||
|
"median": "Median",
|
||||||
|
"mean": "Arithmetic Mean"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"title": "Tibber Price Information & Ratings"
|
"title": "Tibber Price Information & Ratings"
|
||||||
|
|
|
||||||
|
|
@ -135,10 +135,12 @@
|
||||||
"title": "⚙️ Generelle innstillinger",
|
"title": "⚙️ Generelle innstillinger",
|
||||||
"description": "_{step_progress}_\n\n**Konfigurer generelle innstillinger for Tibber prisinformasjon og vurderinger.**\n\n---\n\n**Bruker:** {user_login}",
|
"description": "_{step_progress}_\n\n**Konfigurer generelle innstillinger for Tibber prisinformasjon og vurderinger.**\n\n---\n\n**Bruker:** {user_login}",
|
||||||
"data": {
|
"data": {
|
||||||
"extended_descriptions": "Utvidede beskrivelser"
|
"extended_descriptions": "Utvidede beskrivelser",
|
||||||
|
"average_sensor_display": "Gjennomsnittssensor-visning"
|
||||||
},
|
},
|
||||||
"data_description": {
|
"data_description": {
|
||||||
"extended_descriptions": "Styrer om entitetsattributter inkluderer detaljerte forklaringer og brukstips.\n\n• Deaktivert (standard): Bare kort beskrivelse\n• Aktivert: Detaljert forklaring + praktiske brukseksempler\n\nEksempel:\nDeaktivert = 1 attributt\nAktivert = 2 ekstra attributter"
|
"extended_descriptions": "Styrer om entitetsattributter inkluderer detaljerte forklaringer og brukstips.\n\n• Deaktivert (standard): Bare kort beskrivelse\n• Aktivert: Detaljert forklaring + praktiske brukseksempler\n\nEksempel:\nDeaktivert = 1 attributt\nAktivert = 2 ekstra attributter",
|
||||||
|
"average_sensor_display": "Velg hvilket statistisk mål som skal vises i sensortilstanden for gjennomsnittspris-sensorer. Den andre verdien vises som attributt. Median er mer motstandsdyktig mot ekstremverdier, mens aritmetisk gjennomsnitt representerer tradisjonelt gjennomsnitt. Standard: Median"
|
||||||
},
|
},
|
||||||
"submit": "Videre til trinn 2"
|
"submit": "Videre til trinn 2"
|
||||||
},
|
},
|
||||||
|
|
@ -1118,6 +1120,12 @@
|
||||||
"expensive": "Dyr",
|
"expensive": "Dyr",
|
||||||
"very_expensive": "Svært dyr"
|
"very_expensive": "Svært dyr"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"average_sensor_display": {
|
||||||
|
"options": {
|
||||||
|
"median": "Median",
|
||||||
|
"mean": "Aritmetisk gjennomsnitt"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"title": "Tibber Prisinformasjon & Vurderinger"
|
"title": "Tibber Prisinformasjon & Vurderinger"
|
||||||
|
|
|
||||||
|
|
@ -135,10 +135,12 @@
|
||||||
"title": "⚙️ Algemene instellingen",
|
"title": "⚙️ Algemene instellingen",
|
||||||
"description": "_{step_progress}_\n\n**Configureer algemene instellingen voor Tibber-prijsinformatie en -beoordelingen.**\n\n---\n\n**Gebruiker:** {user_login}",
|
"description": "_{step_progress}_\n\n**Configureer algemene instellingen voor Tibber-prijsinformatie en -beoordelingen.**\n\n---\n\n**Gebruiker:** {user_login}",
|
||||||
"data": {
|
"data": {
|
||||||
"extended_descriptions": "Uitgebreide beschrijvingen"
|
"extended_descriptions": "Uitgebreide beschrijvingen",
|
||||||
|
"average_sensor_display": "Gemiddelde sensor weergave"
|
||||||
},
|
},
|
||||||
"data_description": {
|
"data_description": {
|
||||||
"extended_descriptions": "Bepaalt of entiteitsattributen gedetailleerde uitleg en gebruikstips bevatten.\n\n• Uitgeschakeld (standaard): Alleen korte beschrijving\n• Ingeschakeld: Gedetailleerde uitleg + praktische gebruiksvoorbeelden\n\nVoorbeeld:\nUitgeschakeld = 1 attribuut\nIngeschakeld = 2 extra attributen"
|
"extended_descriptions": "Bepaalt of entiteitsattributen gedetailleerde uitleg en gebruikstips bevatten.\n\n• Uitgeschakeld (standaard): Alleen korte beschrijving\n• Ingeschakeld: Gedetailleerde uitleg + praktische gebruiksvoorbeelden\n\nVoorbeeld:\nUitgeschakeld = 1 attribuut\nIngeschakeld = 2 extra attributen",
|
||||||
|
"average_sensor_display": "Kies welke statistische maat wordt weergegeven in de sensorstatus voor gemiddelde prijssensoren. De andere waarde wordt weergegeven als attribuut. Mediaan is resistenter tegen extreme waarden, terwijl het rekenkundig gemiddelde het traditionele gemiddelde vertegenwoordigt. Standaard: Mediaan"
|
||||||
},
|
},
|
||||||
"submit": "Doorgaan →"
|
"submit": "Doorgaan →"
|
||||||
},
|
},
|
||||||
|
|
@ -1118,6 +1120,12 @@
|
||||||
"expensive": "Duur",
|
"expensive": "Duur",
|
||||||
"very_expensive": "Zeer duur"
|
"very_expensive": "Zeer duur"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"average_sensor_display": {
|
||||||
|
"options": {
|
||||||
|
"median": "Mediaan",
|
||||||
|
"mean": "Rekenkundig gemiddelde"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"title": "Tibber Prijsinformatie & Beoordelingen"
|
"title": "Tibber Prijsinformatie & Beoordelingen"
|
||||||
|
|
|
||||||
|
|
@ -1118,6 +1118,12 @@
|
||||||
"expensive": "Dyrt",
|
"expensive": "Dyrt",
|
||||||
"very_expensive": "Mycket dyrt"
|
"very_expensive": "Mycket dyrt"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"average_sensor_display": {
|
||||||
|
"options": {
|
||||||
|
"median": "Median",
|
||||||
|
"mean": "Aritmetiskt medelvärde"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"title": "Tibber Prisinformation & Betyg"
|
"title": "Tibber Prisinformation & Betyg"
|
||||||
|
|
|
||||||
|
|
@ -11,9 +11,33 @@ if TYPE_CHECKING:
|
||||||
from custom_components.tibber_prices.coordinator.time_service import TibberPricesTimeService
|
from custom_components.tibber_prices.coordinator.time_service import TibberPricesTimeService
|
||||||
|
|
||||||
|
|
||||||
def calculate_trailing_24h_avg(all_prices: list[dict], interval_start: datetime) -> float | None:
|
def calculate_median(prices: list[float]) -> float | None:
|
||||||
"""
|
"""
|
||||||
Calculate trailing 24-hour average price for a given interval.
|
Calculate median from a list of prices.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
prices: List of price values
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Median price, or None if list is empty
|
||||||
|
|
||||||
|
"""
|
||||||
|
if not prices:
|
||||||
|
return None
|
||||||
|
|
||||||
|
sorted_prices = sorted(prices)
|
||||||
|
n = len(sorted_prices)
|
||||||
|
|
||||||
|
if n % 2 == 0:
|
||||||
|
# Even number of elements: average of middle two
|
||||||
|
return (sorted_prices[n // 2 - 1] + sorted_prices[n // 2]) / 2
|
||||||
|
# Odd number of elements: middle element
|
||||||
|
return sorted_prices[n // 2]
|
||||||
|
|
||||||
|
|
||||||
|
def calculate_trailing_24h_avg(all_prices: list[dict], interval_start: datetime) -> tuple[float | None, float | None]:
|
||||||
|
"""
|
||||||
|
Calculate trailing 24-hour average and median price for a given interval.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
all_prices: List of all price data (yesterday, today, tomorrow combined)
|
all_prices: List of all price data (yesterday, today, tomorrow combined)
|
||||||
|
|
@ -21,7 +45,8 @@ def calculate_trailing_24h_avg(all_prices: list[dict], interval_start: datetime)
|
||||||
time: TibberPricesTimeService instance (required)
|
time: TibberPricesTimeService instance (required)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Average price for the 24 hours preceding the interval, or None if no data in window
|
Tuple of (average price, median price) for the 24 hours preceding the interval,
|
||||||
|
or (None, None) if no data in window
|
||||||
|
|
||||||
"""
|
"""
|
||||||
# Define the 24-hour window: from 24 hours before interval_start up to interval_start
|
# Define the 24-hour window: from 24 hours before interval_start up to interval_start
|
||||||
|
|
@ -38,17 +63,19 @@ def calculate_trailing_24h_avg(all_prices: list[dict], interval_start: datetime)
|
||||||
if window_start <= starts_at < window_end:
|
if window_start <= starts_at < window_end:
|
||||||
prices_in_window.append(float(price_data["total"]))
|
prices_in_window.append(float(price_data["total"]))
|
||||||
|
|
||||||
# Calculate average
|
# Calculate average and median
|
||||||
# CRITICAL: Return None instead of 0.0 when no data available
|
# CRITICAL: Return None instead of 0.0 when no data available
|
||||||
# With negative prices, 0.0 could be misinterpreted as a real average value
|
# With negative prices, 0.0 could be misinterpreted as a real average value
|
||||||
if prices_in_window:
|
if prices_in_window:
|
||||||
return sum(prices_in_window) / len(prices_in_window)
|
avg = sum(prices_in_window) / len(prices_in_window)
|
||||||
return None
|
median = calculate_median(prices_in_window)
|
||||||
|
return avg, median
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
|
||||||
def calculate_leading_24h_avg(all_prices: list[dict], interval_start: datetime) -> float | None:
|
def calculate_leading_24h_avg(all_prices: list[dict], interval_start: datetime) -> tuple[float | None, float | None]:
|
||||||
"""
|
"""
|
||||||
Calculate leading 24-hour average price for a given interval.
|
Calculate leading 24-hour average and median price for a given interval.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
all_prices: List of all price data (yesterday, today, tomorrow combined)
|
all_prices: List of all price data (yesterday, today, tomorrow combined)
|
||||||
|
|
@ -56,7 +83,8 @@ def calculate_leading_24h_avg(all_prices: list[dict], interval_start: datetime)
|
||||||
time: TibberPricesTimeService instance (required)
|
time: TibberPricesTimeService instance (required)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Average price for up to 24 hours following the interval, or None if no data in window
|
Tuple of (average price, median price) for up to 24 hours following the interval,
|
||||||
|
or (None, None) if no data in window
|
||||||
|
|
||||||
"""
|
"""
|
||||||
# Define the 24-hour window: from interval_start up to 24 hours after
|
# Define the 24-hour window: from interval_start up to 24 hours after
|
||||||
|
|
@ -73,12 +101,14 @@ def calculate_leading_24h_avg(all_prices: list[dict], interval_start: datetime)
|
||||||
if window_start <= starts_at < window_end:
|
if window_start <= starts_at < window_end:
|
||||||
prices_in_window.append(float(price_data["total"]))
|
prices_in_window.append(float(price_data["total"]))
|
||||||
|
|
||||||
# Calculate average
|
# Calculate average and median
|
||||||
# CRITICAL: Return None instead of 0.0 when no data available
|
# CRITICAL: Return None instead of 0.0 when no data available
|
||||||
# With negative prices, 0.0 could be misinterpreted as a real average value
|
# With negative prices, 0.0 could be misinterpreted as a real average value
|
||||||
if prices_in_window:
|
if prices_in_window:
|
||||||
return sum(prices_in_window) / len(prices_in_window)
|
avg = sum(prices_in_window) / len(prices_in_window)
|
||||||
return None
|
median = calculate_median(prices_in_window)
|
||||||
|
return avg, median
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
|
||||||
def calculate_current_trailing_avg(
|
def calculate_current_trailing_avg(
|
||||||
|
|
@ -378,7 +408,11 @@ def calculate_current_leading_min(
|
||||||
return None
|
return None
|
||||||
|
|
||||||
now = time.now()
|
now = time.now()
|
||||||
return calculate_leading_24h_avg(all_prices, now)
|
# calculate_leading_24h_avg returns (avg, median) - we just need the avg
|
||||||
|
result = calculate_leading_24h_avg(all_prices, now)
|
||||||
|
if isinstance(result, tuple):
|
||||||
|
return result[0] # Return avg only
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def calculate_current_leading_max(
|
def calculate_current_leading_max(
|
||||||
|
|
@ -414,11 +448,11 @@ def calculate_next_n_hours_avg(
|
||||||
hours: int,
|
hours: int,
|
||||||
*,
|
*,
|
||||||
time: TibberPricesTimeService,
|
time: TibberPricesTimeService,
|
||||||
) -> float | None:
|
) -> tuple[float | None, float | None]:
|
||||||
"""
|
"""
|
||||||
Calculate average price for the next N hours starting from the next interval.
|
Calculate average and median price for the next N hours starting from the next interval.
|
||||||
|
|
||||||
This function computes the average of all 15-minute intervals starting from
|
This function computes the average and median of all 15-minute intervals starting from
|
||||||
the next interval (not current) up to N hours into the future.
|
the next interval (not current) up to N hours into the future.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
|
|
@ -427,16 +461,17 @@ def calculate_next_n_hours_avg(
|
||||||
time: TibberPricesTimeService instance (required)
|
time: TibberPricesTimeService instance (required)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Average price for the next N hours, or None if insufficient data
|
Tuple of (average price, median price) for the next N hours,
|
||||||
|
or (None, None) if insufficient data
|
||||||
|
|
||||||
"""
|
"""
|
||||||
if not coordinator_data or hours <= 0:
|
if not coordinator_data or hours <= 0:
|
||||||
return None
|
return None, None
|
||||||
|
|
||||||
# Get all intervals (yesterday, today, tomorrow) via helper
|
# Get all intervals (yesterday, today, tomorrow) via helper
|
||||||
all_prices = get_intervals_for_day_offsets(coordinator_data, [-1, 0, 1])
|
all_prices = get_intervals_for_day_offsets(coordinator_data, [-1, 0, 1])
|
||||||
if not all_prices:
|
if not all_prices:
|
||||||
return None
|
return None, None
|
||||||
|
|
||||||
# Find the current interval index
|
# Find the current interval index
|
||||||
current_idx = None
|
current_idx = None
|
||||||
|
|
@ -451,7 +486,7 @@ def calculate_next_n_hours_avg(
|
||||||
break
|
break
|
||||||
|
|
||||||
if current_idx is None:
|
if current_idx is None:
|
||||||
return None
|
return None, None
|
||||||
|
|
||||||
# Calculate how many intervals are in N hours
|
# Calculate how many intervals are in N hours
|
||||||
intervals_needed = time.minutes_to_intervals(hours * 60)
|
intervals_needed = time.minutes_to_intervals(hours * 60)
|
||||||
|
|
@ -469,7 +504,9 @@ def calculate_next_n_hours_avg(
|
||||||
|
|
||||||
# Return None if no data at all
|
# Return None if no data at all
|
||||||
if not prices_in_window:
|
if not prices_in_window:
|
||||||
return None
|
return None, None
|
||||||
|
|
||||||
# Return average (prefer full period, but allow graceful degradation)
|
# Return average and median (prefer full period, but allow graceful degradation)
|
||||||
return sum(prices_in_window) / len(prices_in_window)
|
avg = sum(prices_in_window) / len(prices_in_window)
|
||||||
|
median = calculate_median(prices_in_window)
|
||||||
|
return avg, median
|
||||||
|
|
|
||||||
|
|
@ -73,7 +73,8 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
|
||||||
"start": "2025-12-07T06:00:00+01:00",
|
"start": "2025-12-07T06:00:00+01:00",
|
||||||
"end": "2025-12-07T08:00:00+01:00",
|
"end": "2025-12-07T08:00:00+01:00",
|
||||||
"duration_minutes": 120,
|
"duration_minutes": 120,
|
||||||
"price_avg": 18.5,
|
"price_mean": 18.5,
|
||||||
|
"price_median": 18.3,
|
||||||
"price_min": 17.2,
|
"price_min": 17.2,
|
||||||
"price_max": 19.8,
|
"price_max": 19.8,
|
||||||
// ... 10+ more attributes × 10-20 periods
|
// ... 10+ more attributes × 10-20 periods
|
||||||
|
|
@ -164,7 +165,7 @@ These attributes **remain in history** because they provide essential analytical
|
||||||
|
|
||||||
### Period Data
|
### Period Data
|
||||||
- `start`, `end`, `duration_minutes` - Core period timing
|
- `start`, `end`, `duration_minutes` - Core period timing
|
||||||
- `price_avg`, `price_min`, `price_max` - Core price statistics
|
- `price_mean`, `price_median`, `price_min`, `price_max` - Core price statistics
|
||||||
|
|
||||||
### High-Level Status
|
### High-Level Status
|
||||||
- `relaxation_active` - Whether relaxation was used (boolean, useful for analyzing when periods needed relaxation)
|
- `relaxation_active` - Whether relaxation was used (boolean, useful for analyzing when periods needed relaxation)
|
||||||
|
|
|
||||||
|
|
@ -516,7 +516,8 @@ automation:
|
||||||
start: "2025-11-11T02:00:00+01:00" # Period start time
|
start: "2025-11-11T02:00:00+01:00" # Period start time
|
||||||
end: "2025-11-11T05:00:00+01:00" # Period end time
|
end: "2025-11-11T05:00:00+01:00" # Period end time
|
||||||
duration_minutes: 180 # Duration in minutes
|
duration_minutes: 180 # Duration in minutes
|
||||||
price_avg: 18.5 # Average price in the period
|
price_mean: 18.5 # Arithmetic mean price in the period
|
||||||
|
price_median: 18.3 # Median price in the period
|
||||||
rating_level: "LOW" # All intervals have LOW rating
|
rating_level: "LOW" # All intervals have LOW rating
|
||||||
|
|
||||||
# Relaxation info (shows if filter loosening was needed):
|
# Relaxation info (shows if filter loosening was needed):
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue