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:
Julian Pawlowski 2025-12-08 17:53:40 +00:00
parent 0f56e80838
commit 51a99980df
28 changed files with 447 additions and 97 deletions

View file

@ -2408,7 +2408,8 @@ attributes = {
"rating_level": ..., # Price rating (LOW, NORMAL, HIGH)
# 3. Price statistics (how much does it cost?)
"price_avg": ...,
"price_mean": ...,
"price_median": ...,
"price_min": ...,
"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",
"end": "2025-11-08T15:00:00+01:00",
"rating_level": "LOW",
"price_avg": 18.5,
"price_mean": 18.5,
"price_median": 18.3,
"interval_count": 4,
"intervals": [...]
}
@ -2619,7 +2621,7 @@ This ensures timestamp is always the first key in the attribute dict, regardless
"interval_count": 4,
"rating_level": "LOW",
"start": "2025-11-08T14:00:00+01:00",
"price_avg": 18.5,
"price_mean": 18.5,
"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:**
- Period averages: `period_price_avg` (average across the period)
- Reference comparisons: `period_price_diff_from_daily_min` (period avg vs daily min)
- Period statistics: `price_mean` (arithmetic mean), `price_median` (median value)
- 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)
### Before Adding New Attributes

View file

@ -168,8 +168,10 @@ def add_decision_attributes(attributes: dict, current_period: dict) -> None:
def add_price_attributes(attributes: dict, current_period: dict) -> None:
"""Add price statistics attributes (priority 3)."""
if "price_avg" in current_period:
attributes["price_avg"] = current_period["price_avg"]
if "price_mean" in current_period:
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:
attributes["price_min"] = current_period["price_min"]
if "price_max" in current_period:
@ -234,7 +236,7 @@ def build_final_attributes_simple(
Attributes are ordered following the documented priority:
1. Time information (timestamp, start, end, duration)
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_%)
5. Detail information (period_interval_count, period_position, periods_total, periods_remaining)
6. Relaxation information (relaxation_active, relaxation_level, relaxation_threshold_original_%,

View file

@ -40,6 +40,7 @@ class TibberPricesBinarySensor(TibberPricesEntity, BinarySensorEntity, RestoreEn
# See: https://developers.home-assistant.io/docs/core/entity/#excluding-state-attributes-from-recorder-history
_unrecorded_attributes = frozenset(
{
"timestamp",
# Descriptions/Help Text (static, large)
"description",
"usage_tips",

View file

@ -11,6 +11,7 @@ import voluptuous as vol
from custom_components.tibber_prices.const import (
BEST_PRICE_MAX_LEVEL_OPTIONS,
CONF_AVERAGE_SENSOR_DISPLAY,
CONF_BEST_PRICE_FLEX,
CONF_BEST_PRICE_MAX_LEVEL,
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_MODERATE,
CONF_VOLATILITY_THRESHOLD_VERY_HIGH,
DEFAULT_AVERAGE_SENSOR_DISPLAY,
DEFAULT_BEST_PRICE_FLEX,
DEFAULT_BEST_PRICE_MAX_LEVEL,
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,
default=options.get(CONF_EXTENDED_DESCRIPTIONS, DEFAULT_EXTENDED_DESCRIPTIONS),
): 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",
),
),
}
)

View file

@ -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_PRICE_RATING_THRESHOLD_LOW = "price_rating_threshold_low"
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_FALLING = "price_trend_threshold_falling"
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_PRICE_RATING_THRESHOLD_LOW = -10 # Default rating threshold low 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_FALLING = -3 # Default trend threshold for falling prices (%, negative value)
# Default volatility thresholds (relative values using coefficient of variation)

View file

@ -14,6 +14,7 @@ if TYPE_CHECKING:
TibberPricesPeriodStatistics,
TibberPricesThresholdConfig,
)
from custom_components.tibber_prices.utils.average import calculate_median
from custom_components.tibber_prices.utils.price import (
aggregate_period_levels,
aggregate_period_ratings,
@ -22,7 +23,7 @@ from custom_components.tibber_prices.utils.price import (
def calculate_period_price_diff(
price_avg: float,
price_mean: float,
start_time: datetime,
price_context: dict[str, Any],
) -> tuple[float | None, float | None]:
@ -47,7 +48,7 @@ def calculate_period_price_diff(
# Convert reference price to minor units (ct/øre)
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
if ref_price_minor != 0:
# 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
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]
if not prices_minor:
return {
"price_avg": 0.0,
"price_mean": 0.0,
"price_median": 0.0,
"price_min": 0.0,
"price_max": 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_max = round(max(prices_minor), 2)
price_spread = round(price_max - price_min, 2)
return {
"price_avg": price_avg,
"price_mean": price_mean,
"price_median": price_median,
"price_min": price_min,
"price_max": price_max,
"price_spread": price_spread,
@ -147,7 +152,8 @@ def build_period_summary_dict(
"rating_level": stats.aggregated_rating,
"rating_difference_%": stats.rating_difference_pct,
# 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_max": stats.price_max,
"price_spread": stats.price_spread,
@ -290,7 +296,7 @@ def extract_period_summaries(
# Calculate period price difference from daily reference
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)
@ -324,7 +330,8 @@ def extract_period_summaries(
aggregated_level=aggregated_level,
aggregated_rating=aggregated_rating,
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_max=price_stats["price_max"],
price_spread=price_stats["price_spread"],

View file

@ -56,7 +56,8 @@ class TibberPricesPeriodStatistics(NamedTuple):
aggregated_level: str | None
aggregated_rating: str | None
rating_difference_pct: float | None
price_avg: float
price_mean: float
price_median: float
price_min: float
price_max: float
price_spread: float

View file

@ -77,6 +77,8 @@ def build_sensor_attributes(
coordinator: TibberPricesDataUpdateCoordinator,
native_value: Any,
cached_data: dict,
*,
config_entry: TibberPricesConfigEntry,
) -> dict[str, Any] | None:
"""
Build attributes for a sensor based on its key.
@ -88,6 +90,7 @@ def build_sensor_attributes(
coordinator: The data update coordinator
native_value: The current native value of the sensor
cached_data: Dictionary containing cached sensor data
config_entry: Config entry for user preferences
Returns:
Dictionary of attributes or None if no attributes should be added
@ -127,6 +130,7 @@ def build_sensor_attributes(
native_value=native_value,
cached_data=cached_data,
time=time,
config_entry=config_entry,
)
elif key in [
"trailing_price_average",
@ -136,9 +140,23 @@ def build_sensor_attributes(
"leading_price_min",
"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_"):
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(
pattern in key
for pattern in [
@ -160,6 +178,7 @@ def build_sensor_attributes(
key=key,
cached_data=cached_data,
time=time,
config_entry=config_entry,
)
elif key == "data_lifecycle_status":
# Lifecycle sensor uses dedicated builder with calculator

View file

@ -14,6 +14,9 @@ if TYPE_CHECKING:
from datetime import datetime
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:
@ -83,6 +86,7 @@ def add_statistics_attributes(
cached_data: dict,
*,
time: TibberPricesTimeService,
config_entry: TibberPricesConfigEntry,
) -> None:
"""
Add attributes for statistics and rating sensors.
@ -92,6 +96,7 @@ def add_statistics_attributes(
key: The sensor entity key
cached_data: Dictionary containing cached sensor data
time: TibberPricesTimeService instance (required)
config_entry: Config entry for user preferences
"""
# Data timestamp sensor - shows API fetch time
@ -126,10 +131,17 @@ def add_statistics_attributes(
attributes["timestamp"] = extreme_starts_at
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"}
if key in daily_avg_sensors:
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
# Daily aggregated level/rating sensors - show midnight to indicate whole day

View file

@ -11,17 +11,22 @@ if TYPE_CHECKING:
TibberPricesDataUpdateCoordinator,
)
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
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,
key: str,
coordinator: TibberPricesDataUpdateCoordinator,
*,
time: TibberPricesTimeService,
cached_data: dict | None = None,
config_entry: TibberPricesConfigEntry | None = None,
) -> None:
"""
Add attributes for next N hours average price sensors.
@ -31,6 +36,8 @@ def add_next_avg_attributes(
key: The sensor entity key
coordinator: The data update coordinator
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)
@ -62,6 +69,16 @@ def add_next_avg_attributes(
attributes["interval_count"] = len(intervals_in_window)
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(
coordinator: TibberPricesDataUpdateCoordinator,

View 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

View file

@ -17,7 +17,9 @@ if TYPE_CHECKING:
TibberPricesDataUpdateCoordinator,
)
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
@ -29,6 +31,7 @@ def add_current_interval_price_attributes( # noqa: PLR0913
cached_data: dict,
*,
time: TibberPricesTimeService,
config_entry: TibberPricesConfigEntry,
) -> None:
"""
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
cached_data: Dictionary containing cached sensor data
time: TibberPricesTimeService instance (required)
config_entry: Config entry for user preferences
"""
now = time.now()
@ -108,6 +112,15 @@ def add_current_interval_price_attributes( # noqa: PLR0913
if 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_level_attributes_for_sensor(
attributes=attributes,

View file

@ -11,6 +11,9 @@ if TYPE_CHECKING:
TibberPricesDataUpdateCoordinator,
)
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:
@ -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
def add_average_price_attributes(
def add_average_price_attributes( # noqa: PLR0913
attributes: dict,
key: str,
coordinator: TibberPricesDataUpdateCoordinator,
*,
time: TibberPricesTimeService,
cached_data: dict | None = None,
config_entry: TibberPricesConfigEntry | None = None,
) -> None:
"""
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
coordinator: The data update coordinator
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
@ -98,3 +105,13 @@ def add_average_price_attributes(
attributes["timestamp"] = intervals_in_window[0].get("startsAt")
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,
)

View file

@ -49,8 +49,8 @@ class TibberPricesDailyStatCalculator(TibberPricesBaseCalculator):
self,
*,
day: str = "today",
stat_func: Callable[[list[float]], float],
) -> float | None:
stat_func: Callable[[list[float]], float] | Callable[[list[float]], tuple[float, float | None]],
) -> float | tuple[float, float | None] | None:
"""
Unified method for daily statistics (min/max/avg within calendar day).
@ -59,10 +59,12 @@ class TibberPricesDailyStatCalculator(TibberPricesBaseCalculator):
Args:
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:
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():
@ -97,7 +99,21 @@ class TibberPricesDailyStatCalculator(TibberPricesBaseCalculator):
# Find the extreme value and store its interval for later use in attributes
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
for pi in price_intervals:

View file

@ -32,7 +32,7 @@ class TibberPricesRollingHourCalculator(TibberPricesBaseCalculator):
*,
hour_offset: int = 0,
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.
@ -44,7 +44,7 @@ class TibberPricesRollingHourCalculator(TibberPricesBaseCalculator):
Returns:
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.)
- "rating": str (aggregated rating: "low", "normal", "high")
@ -81,7 +81,7 @@ class TibberPricesRollingHourCalculator(TibberPricesBaseCalculator):
self,
window_data: list[dict],
value_type: str,
) -> str | float | None:
) -> str | float | tuple[float | None, float | None] | None:
"""
Aggregate data from multiple intervals based on value type.
@ -90,7 +90,10 @@ class TibberPricesRollingHourCalculator(TibberPricesBaseCalculator):
value_type: "price" | "level" | "rating".
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
@ -103,9 +106,12 @@ class TibberPricesRollingHourCalculator(TibberPricesBaseCalculator):
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 = {
"price": lambda data: aggregate_price_data(data),
"level": lambda data: aggregate_level_data(data),
"rating": lambda data: aggregate_rating_data(data, threshold_low, threshold_high),
}

View file

@ -97,7 +97,7 @@ class TibberPricesTrendCalculator(TibberPricesBaseCalculator):
next_interval_start = time.get_next_interval_start()
# 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:
return None

View file

@ -24,7 +24,7 @@ class TibberPricesWindow24hCalculator(TibberPricesBaseCalculator):
self,
*,
stat_func: Callable,
) -> float | None:
) -> float | tuple[float, float | None] | None:
"""
Unified method for 24-hour sliding window statistics.
@ -37,13 +37,27 @@ class TibberPricesWindow24hCalculator(TibberPricesBaseCalculator):
Returns:
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():
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:
return None

View file

@ -9,8 +9,10 @@ from custom_components.tibber_prices.binary_sensor.attributes import (
get_price_intervals_attributes,
)
from custom_components.tibber_prices.const import (
CONF_AVERAGE_SENSOR_DISPLAY,
CONF_PRICE_RATING_THRESHOLD_HIGH,
CONF_PRICE_RATING_THRESHOLD_LOW,
DEFAULT_AVERAGE_SENSOR_DISPLAY,
DEFAULT_PRICE_RATING_THRESHOLD_HIGH,
DEFAULT_PRICE_RATING_THRESHOLD_LOW,
DOMAIN,
@ -99,6 +101,7 @@ class TibberPricesSensor(TibberPricesEntity, RestoreSensor):
# See: https://developers.home-assistant.io/docs/core/entity/#excluding-state-attributes-from-recorder-history
_unrecorded_attributes = frozenset(
{
"timestamp",
# Descriptions/Help Text (static, large)
"description",
"usage_tips",
@ -158,6 +161,8 @@ class TibberPricesSensor(TibberPricesEntity, RestoreSensor):
self.entity_description = entity_description
self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{entity_description.key}"
self._attr_has_entity_name = True
# Cached data for attributes (e.g., median values)
self.cached_data: dict[str, Any] = {}
# Instantiate calculators
self._metadata_calculator = TibberPricesMetadataCalculator(coordinator)
self._volatility_calculator = TibberPricesVolatilityCalculator(coordinator)
@ -376,7 +381,15 @@ class TibberPricesSensor(TibberPricesEntity, RestoreSensor):
if not window_data:
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
@ -563,10 +576,14 @@ class TibberPricesSensor(TibberPricesEntity, RestoreSensor):
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:
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)
return round(avg_price * 100, 2)
@ -773,7 +790,7 @@ class TibberPricesSensor(TibberPricesEntity, RestoreSensor):
return True
@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."""
try:
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
if self.entity_description.key == "current_interval_price_level":
return self._interval_calculator.get_price_level_value()
return self._value_getter()
result = self._value_getter()
except (KeyError, ValueError, TypeError) as ex:
self.coordinator.logger.exception(
"Error getting sensor value",
@ -791,6 +809,48 @@ class TibberPricesSensor(TibberPricesEntity, RestoreSensor):
},
)
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
def native_unit_of_measurement(self) -> str | None:
@ -933,7 +993,12 @@ class TibberPricesSensor(TibberPricesEntity, RestoreSensor):
return self._get_chart_metadata_attributes()
# 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(),
"current_trend_attributes": self._trend_calculator.get_current_trend_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),
"lifecycle_calculator": self._lifecycle_calculator, # For lifecycle sensor attributes
}
)
# Use the centralized attribute builder
return build_sensor_attributes(
@ -953,6 +1019,7 @@ class TibberPricesSensor(TibberPricesEntity, RestoreSensor):
coordinator=self.coordinator,
native_value=self.native_value,
cached_data=cached_data,
config_entry=self.coordinator.config_entry,
)
def _get_rolling_hour_level_for_cached_data(self, key: str) -> str | None:

View file

@ -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.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 (
aggregate_price_levels,
aggregate_price_rating,
@ -35,22 +36,26 @@ if TYPE_CHECKING:
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:
window_data: List of price interval dictionaries with 'total' key
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]
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 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:
@ -119,7 +124,7 @@ def aggregate_window_data(
"""
# Map value types to aggregation functions
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),
"rating": lambda data: aggregate_rating_data(data, threshold_low, threshold_high),
}

View file

@ -11,6 +11,7 @@ from custom_components.tibber_prices.utils.average import (
calculate_current_trailing_avg,
calculate_current_trailing_max,
calculate_current_trailing_min,
calculate_median,
)
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),
"average_price_today": lambda: daily_stat_calculator.get_daily_stat_value(
day="today",
stat_func=lambda prices: sum(prices) / len(prices),
stat_func=lambda prices: (sum(prices) / len(prices), calculate_median(prices)),
),
# Tomorrow statistics sensors
"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),
"average_price_tomorrow": lambda: daily_stat_calculator.get_daily_stat_value(
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
"yesterday_price_level": lambda: daily_stat_calculator.get_daily_aggregated_value(

View file

@ -135,10 +135,12 @@
"title": "⚙️ Allgemeine Einstellungen",
"description": "_{step_progress}_\n\n**Konfiguriere allgemeine Einstellungen für Tibber-Preisinformationen und -bewertungen.**\n\n---\n\n**Benutzer:** {user_login}",
"data": {
"extended_descriptions": "Erweiterte Beschreibungen"
"extended_descriptions": "Erweiterte Beschreibungen",
"average_sensor_display": "Durchschnittsensor-Anzeige"
},
"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 →"
},
@ -151,11 +153,13 @@
"description": "Definiere die Einstufungen für die Preisbewertung.",
"data": {
"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": {
"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",
"very_expensive": "Sehr teuer"
}
},
"average_sensor_display": {
"options": {
"median": "Median",
"mean": "Arithmetisches Mittel"
}
}
},
"title": "Tibber Preisinformationen & Bewertungen"

View file

@ -135,10 +135,12 @@
"title": "⚙️ General Settings",
"description": "_{step_progress}_\n\n**Configure general settings for Tibber Price Information & Ratings.**\n\n---\n\n**User:** {user_login}",
"data": {
"extended_descriptions": "Extended Descriptions"
"extended_descriptions": "Extended Descriptions",
"average_sensor_display": "Average Sensor Display"
},
"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 →"
},
@ -1118,6 +1120,12 @@
"expensive": "Expensive",
"very_expensive": "Very expensive"
}
},
"average_sensor_display": {
"options": {
"median": "Median",
"mean": "Arithmetic Mean"
}
}
},
"title": "Tibber Price Information & Ratings"

View file

@ -135,10 +135,12 @@
"title": "⚙️ Generelle innstillinger",
"description": "_{step_progress}_\n\n**Konfigurer generelle innstillinger for Tibber prisinformasjon og vurderinger.**\n\n---\n\n**Bruker:** {user_login}",
"data": {
"extended_descriptions": "Utvidede beskrivelser"
"extended_descriptions": "Utvidede beskrivelser",
"average_sensor_display": "Gjennomsnittssensor-visning"
},
"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"
},
@ -1118,6 +1120,12 @@
"expensive": "Dyr",
"very_expensive": "Svært dyr"
}
},
"average_sensor_display": {
"options": {
"median": "Median",
"mean": "Aritmetisk gjennomsnitt"
}
}
},
"title": "Tibber Prisinformasjon & Vurderinger"

View file

@ -135,10 +135,12 @@
"title": "⚙️ Algemene instellingen",
"description": "_{step_progress}_\n\n**Configureer algemene instellingen voor Tibber-prijsinformatie en -beoordelingen.**\n\n---\n\n**Gebruiker:** {user_login}",
"data": {
"extended_descriptions": "Uitgebreide beschrijvingen"
"extended_descriptions": "Uitgebreide beschrijvingen",
"average_sensor_display": "Gemiddelde sensor weergave"
},
"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 →"
},
@ -1118,6 +1120,12 @@
"expensive": "Duur",
"very_expensive": "Zeer duur"
}
},
"average_sensor_display": {
"options": {
"median": "Mediaan",
"mean": "Rekenkundig gemiddelde"
}
}
},
"title": "Tibber Prijsinformatie & Beoordelingen"

View file

@ -1118,6 +1118,12 @@
"expensive": "Dyrt",
"very_expensive": "Mycket dyrt"
}
},
"average_sensor_display": {
"options": {
"median": "Median",
"mean": "Aritmetiskt medelvärde"
}
}
},
"title": "Tibber Prisinformation & Betyg"

View file

@ -11,9 +11,33 @@ if TYPE_CHECKING:
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:
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)
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
@ -38,17 +63,19 @@ def calculate_trailing_24h_avg(all_prices: list[dict], interval_start: datetime)
if window_start <= starts_at < window_end:
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
# With negative prices, 0.0 could be misinterpreted as a real average value
if prices_in_window:
return sum(prices_in_window) / len(prices_in_window)
return None
avg = sum(prices_in_window) / len(prices_in_window)
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:
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)
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
@ -73,12 +101,14 @@ def calculate_leading_24h_avg(all_prices: list[dict], interval_start: datetime)
if window_start <= starts_at < window_end:
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
# With negative prices, 0.0 could be misinterpreted as a real average value
if prices_in_window:
return sum(prices_in_window) / len(prices_in_window)
return None
avg = sum(prices_in_window) / len(prices_in_window)
median = calculate_median(prices_in_window)
return avg, median
return None, None
def calculate_current_trailing_avg(
@ -378,7 +408,11 @@ def calculate_current_leading_min(
return None
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(
@ -414,11 +448,11 @@ def calculate_next_n_hours_avg(
hours: int,
*,
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.
Args:
@ -427,16 +461,17 @@ def calculate_next_n_hours_avg(
time: TibberPricesTimeService instance (required)
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:
return None
return None, None
# Get all intervals (yesterday, today, tomorrow) via helper
all_prices = get_intervals_for_day_offsets(coordinator_data, [-1, 0, 1])
if not all_prices:
return None
return None, None
# Find the current interval index
current_idx = None
@ -451,7 +486,7 @@ def calculate_next_n_hours_avg(
break
if current_idx is None:
return None
return None, None
# Calculate how many intervals are in N hours
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
if not prices_in_window:
return None
return None, None
# Return average (prefer full period, but allow graceful degradation)
return sum(prices_in_window) / len(prices_in_window)
# Return average and median (prefer full period, but allow graceful degradation)
avg = sum(prices_in_window) / len(prices_in_window)
median = calculate_median(prices_in_window)
return avg, median

View file

@ -73,7 +73,8 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
"start": "2025-12-07T06:00:00+01:00",
"end": "2025-12-07T08:00:00+01:00",
"duration_minutes": 120,
"price_avg": 18.5,
"price_mean": 18.5,
"price_median": 18.3,
"price_min": 17.2,
"price_max": 19.8,
// ... 10+ more attributes × 10-20 periods
@ -164,7 +165,7 @@ These attributes **remain in history** because they provide essential analytical
### Period Data
- `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
- `relaxation_active` - Whether relaxation was used (boolean, useful for analyzing when periods needed relaxation)

View file

@ -516,7 +516,8 @@ automation:
start: "2025-11-11T02:00:00+01:00" # Period start time
end: "2025-11-11T05:00:00+01:00" # Period end time
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
# Relaxation info (shows if filter loosening was needed):