From abb02083a72e90625acdd7cc92a4a10c289e0ccd Mon Sep 17 00:00:00 2001 From: Julian Pawlowski <75446+jpawlowski@users.noreply.github.com> Date: Thu, 18 Dec 2025 15:12:30 +0000 Subject: [PATCH] feat(sensors): always show both mean and median in average sensor attributes Implemented configurable display format (mean/median/both) while always calculating and exposing both price_mean and price_median attributes. Core changes: - utils/average.py: Refactored calculate_mean_median() to always return both values, added comprehensive None handling (117 lines changed) - sensor/attributes/helpers.py: Always include both attributes regardless of user display preference (41 lines) - sensor/core.py: Dynamic _unrecorded_attributes based on display setting (55 lines), extracted helper methods to reduce complexity - Updated all calculators (rolling_hour, trend, volatility, window_24h) to use new always-both approach Impact: Users can switch display format in UI without losing historical data. Automation authors always have access to both statistical measures. --- .../sensor/attributes/helpers.py | 41 +++--- .../sensor/calculators/rolling_hour.py | 4 +- .../tibber_prices/sensor/calculators/trend.py | 44 +++---- .../sensor/calculators/volatility.py | 3 +- .../sensor/calculators/window_24h.py | 10 +- .../tibber_prices/sensor/core.py | 74 +++++++++-- .../tibber_prices/sensor/definitions.py | 4 +- .../tibber_prices/sensor/helpers.py | 12 +- .../tibber_prices/sensor/value_getters.py | 13 +- .../tibber_prices/utils/__init__.py | 16 ++- .../tibber_prices/utils/average.py | 117 +++++++++++------- 11 files changed, 208 insertions(+), 130 deletions(-) diff --git a/custom_components/tibber_prices/sensor/attributes/helpers.py b/custom_components/tibber_prices/sensor/attributes/helpers.py index ec40a52..f5a041e 100644 --- a/custom_components/tibber_prices/sensor/attributes/helpers.py +++ b/custom_components/tibber_prices/sensor/attributes/helpers.py @@ -4,11 +4,6 @@ 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 @@ -18,35 +13,29 @@ def add_alternate_average_attribute( cached_data: dict, base_key: str, *, - config_entry: TibberPricesConfigEntry, + config_entry: TibberPricesConfigEntry, # noqa: ARG001 ) -> None: """ - Add the alternate average value (mean or median) as attribute. + Add both average values (mean and median) as attributes. - If user selected "median" as state display, adds "price_mean" as attribute. - If user selected "mean" as state display, adds "price_median" as attribute. + This ensures automations work consistently regardless of which value + is displayed in the state. Both values are always available as attributes. + + Note: To avoid duplicate recording, the value used as state should be + excluded from recorder via dynamic _unrecorded_attributes in sensor core. 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 + config_entry: Config entry for user preferences (used to determine which value is in state) """ - # Get user preference for which value to display in state - display_mode = config_entry.options.get( - CONF_AVERAGE_SENSOR_DISPLAY, - DEFAULT_AVERAGE_SENSOR_DISPLAY, - ) + # Always add both mean and median values as attributes + mean_value = cached_data.get(f"{base_key}_mean") + if mean_value is not None: + attributes["price_mean"] = mean_value - # 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 + median_value = cached_data.get(f"{base_key}_median") + if median_value is not None: + attributes["price_median"] = median_value diff --git a/custom_components/tibber_prices/sensor/calculators/rolling_hour.py b/custom_components/tibber_prices/sensor/calculators/rolling_hour.py index 13c9d6e..10ed65a 100644 --- a/custom_components/tibber_prices/sensor/calculators/rolling_hour.py +++ b/custom_components/tibber_prices/sensor/calculators/rolling_hour.py @@ -11,8 +11,8 @@ from custom_components.tibber_prices.const import ( from custom_components.tibber_prices.coordinator.helpers import get_intervals_for_day_offsets from custom_components.tibber_prices.entity_utils import find_rolling_hour_center_index from custom_components.tibber_prices.sensor.helpers import ( + aggregate_average_data, aggregate_level_data, - aggregate_price_data, aggregate_rating_data, ) @@ -108,7 +108,7 @@ class TibberPricesRollingHourCalculator(TibberPricesBaseCalculator): # Handle price aggregation - return tuple directly if value_type == "price": - return aggregate_price_data(window_data, self.config_entry) + return aggregate_average_data(window_data, self.config_entry) # Map other value types to aggregation functions aggregators = { diff --git a/custom_components/tibber_prices/sensor/calculators/trend.py b/custom_components/tibber_prices/sensor/calculators/trend.py index fda4437..7e7ecfd 100644 --- a/custom_components/tibber_prices/sensor/calculators/trend.py +++ b/custom_components/tibber_prices/sensor/calculators/trend.py @@ -17,7 +17,7 @@ from typing import TYPE_CHECKING, Any from custom_components.tibber_prices.const import get_display_unit_factor from custom_components.tibber_prices.coordinator.helpers import get_intervals_for_day_offsets -from custom_components.tibber_prices.utils.average import calculate_next_n_hours_avg +from custom_components.tibber_prices.utils.average import calculate_mean, calculate_next_n_hours_mean from custom_components.tibber_prices.utils.price import ( calculate_price_trend, find_price_data_for_interval, @@ -97,9 +97,9 @@ class TibberPricesTrendCalculator(TibberPricesBaseCalculator): # Get next interval timestamp (basis for calculation) 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) - if future_avg is None: + # Get future mean price (ignore median for trend calculation) + future_mean, _ = calculate_next_n_hours_mean(self.coordinator.data, hours, time=self.coordinator.time) + if future_mean is None: return None # Get configured thresholds from options @@ -117,7 +117,7 @@ class TibberPricesTrendCalculator(TibberPricesBaseCalculator): # Calculate trend with volatility-adaptive thresholds trend_state, diff_pct = calculate_price_trend( current_interval_price, - future_avg, + future_mean, threshold_rising=threshold_rising, threshold_falling=threshold_falling, volatility_adjustment=True, # Always enabled @@ -141,7 +141,7 @@ class TibberPricesTrendCalculator(TibberPricesBaseCalculator): self._trend_attributes = { "timestamp": next_interval_start, f"trend_{hours}h_%": round(diff_pct, 1), - f"next_{hours}h_avg": round(future_avg * factor, 2), + f"next_{hours}h_avg": round(future_mean * factor, 2), "interval_count": lookahead_intervals, "threshold_rising": threshold_rising, "threshold_falling": threshold_falling, @@ -282,7 +282,7 @@ class TibberPricesTrendCalculator(TibberPricesBaseCalculator): later_prices.append(float(price)) if later_prices: - return sum(later_prices) / len(later_prices) + return calculate_mean(later_prices) return None @@ -349,11 +349,11 @@ class TibberPricesTrendCalculator(TibberPricesBaseCalculator): # Combine momentum + future outlook to get ACTUAL current trend if len(future_intervals) >= min_intervals_for_trend and future_prices: - future_avg = sum(future_prices) / len(future_prices) + future_mean = calculate_mean(future_prices) current_trend_state = self._combine_momentum_with_future( current_momentum=current_momentum, current_price=current_price, - future_avg=future_avg, + future_mean=future_mean, context={ "all_intervals": all_intervals, "current_index": current_index, @@ -466,7 +466,7 @@ class TibberPricesTrendCalculator(TibberPricesBaseCalculator): *, current_momentum: str, current_price: float, - future_avg: float, + future_mean: float, context: dict, ) -> str: """ @@ -475,7 +475,7 @@ class TibberPricesTrendCalculator(TibberPricesBaseCalculator): Args: current_momentum: Current momentum direction (rising/falling/stable) current_price: Current interval price - future_avg: Average price in future window + future_mean: Average price in future window context: Dict with all_intervals, current_index, lookahead_intervals, thresholds Returns: @@ -484,11 +484,11 @@ class TibberPricesTrendCalculator(TibberPricesBaseCalculator): """ if current_momentum == "rising": # We're in uptrend - does it continue? - return "rising" if future_avg >= current_price * 0.98 else "falling" + return "rising" if future_mean >= current_price * 0.98 else "falling" if current_momentum == "falling": # We're in downtrend - does it continue? - return "falling" if future_avg <= current_price * 1.02 else "rising" + return "falling" if future_mean <= current_price * 1.02 else "rising" # current_momentum == "stable" - what's coming? all_intervals = context["all_intervals"] @@ -499,7 +499,7 @@ class TibberPricesTrendCalculator(TibberPricesBaseCalculator): lookahead_for_volatility = all_intervals[current_index : current_index + lookahead_intervals] trend_state, _ = calculate_price_trend( current_price, - future_avg, + future_mean, threshold_rising=thresholds["rising"], threshold_falling=thresholds["falling"], volatility_adjustment=True, @@ -530,13 +530,13 @@ class TibberPricesTrendCalculator(TibberPricesBaseCalculator): if not standard_future_prices: return "stable" - standard_future_avg = sum(standard_future_prices) / len(standard_future_prices) + standard_future_mean = calculate_mean(standard_future_prices) current_price = float(current_interval["total"]) standard_lookahead_volatility = all_intervals[current_index : current_index + standard_lookahead] current_trend_3h, _ = calculate_price_trend( current_price, - standard_future_avg, + standard_future_mean, threshold_rising=thresholds["rising"], threshold_falling=thresholds["falling"], volatility_adjustment=True, @@ -601,14 +601,14 @@ class TibberPricesTrendCalculator(TibberPricesBaseCalculator): if not future_prices: continue - future_avg = sum(future_prices) / len(future_prices) + future_mean = calculate_mean(future_prices) price = float(interval["total"]) # Calculate trend at this past point lookahead_for_volatility = all_intervals[i : i + intervals_in_3h] trend_state, _ = calculate_price_trend( price, - future_avg, + future_mean, threshold_rising=thresholds["rising"], threshold_falling=thresholds["falling"], volatility_adjustment=True, @@ -673,14 +673,14 @@ class TibberPricesTrendCalculator(TibberPricesBaseCalculator): if not future_prices: continue - future_avg = sum(future_prices) / len(future_prices) + future_mean = calculate_mean(future_prices) current_price = float(interval["total"]) # Calculate trend at this future point lookahead_for_volatility = all_intervals[i : i + intervals_in_3h] trend_state, _ = calculate_price_trend( current_price, - future_avg, + future_mean, threshold_rising=thresholds["rising"], threshold_falling=thresholds["falling"], volatility_adjustment=True, @@ -706,8 +706,8 @@ class TibberPricesTrendCalculator(TibberPricesBaseCalculator): "minutes_until_change": minutes_until, "current_price_now": round(float(current_interval["total"]) * factor, 2), "price_at_change": round(current_price * factor, 2), - "avg_after_change": round(future_avg * factor, 2), - "trend_diff_%": round((future_avg - current_price) / current_price * 100, 1), + "avg_after_change": round(future_mean * factor, 2), + "trend_diff_%": round((future_mean - current_price) / current_price * 100, 1), } return interval_start diff --git a/custom_components/tibber_prices/sensor/calculators/volatility.py b/custom_components/tibber_prices/sensor/calculators/volatility.py index 88b0b08..1e0d024 100644 --- a/custom_components/tibber_prices/sensor/calculators/volatility.py +++ b/custom_components/tibber_prices/sensor/calculators/volatility.py @@ -10,6 +10,7 @@ from custom_components.tibber_prices.sensor.attributes import ( add_volatility_type_attributes, get_prices_for_volatility, ) +from custom_components.tibber_prices.utils.average import calculate_mean from custom_components.tibber_prices.utils.price import calculate_volatility_level from .base import TibberPricesBaseCalculator @@ -75,7 +76,7 @@ class TibberPricesVolatilityCalculator(TibberPricesBaseCalculator): price_max = max(prices_to_analyze) spread = price_max - price_min # Use arithmetic mean for volatility calculation (required for coefficient of variation) - price_mean = sum(prices_to_analyze) / len(prices_to_analyze) + price_mean = calculate_mean(prices_to_analyze) # Convert to display currency unit based on configuration factor = get_display_unit_factor(self.config_entry) diff --git a/custom_components/tibber_prices/sensor/calculators/window_24h.py b/custom_components/tibber_prices/sensor/calculators/window_24h.py index 213329f..73c61b3 100644 --- a/custom_components/tibber_prices/sensor/calculators/window_24h.py +++ b/custom_components/tibber_prices/sensor/calculators/window_24h.py @@ -33,11 +33,11 @@ class TibberPricesWindow24hCalculator(TibberPricesBaseCalculator): - "leading": Next 24 hours (96 intervals after current) Args: - stat_func: Function from average_utils (e.g., calculate_current_trailing_avg). + stat_func: Function from average_utils (e.g., calculate_current_trailing_mean). Returns: Price value in subunit currency units (cents/øre), or None if unavailable. - For average functions: tuple of (avg, median) where median may be None. + For mean functions: tuple of (mean, median) where median may be None. For min/max functions: single float value. """ @@ -46,19 +46,19 @@ class TibberPricesWindow24hCalculator(TibberPricesBaseCalculator): result = stat_func(self.coordinator_data, time=self.coordinator.time) - # Check if result is a tuple (avg, median) from average functions + # Check if result is a tuple (mean, median) from mean functions if isinstance(result, tuple): value, median = result if value is None: return None # Convert to display currency units based on config - avg_result = round(get_price_value(value, config_entry=self.coordinator.config_entry), 2) + mean_result = round(get_price_value(value, config_entry=self.coordinator.config_entry), 2) median_result = ( round(get_price_value(median, config_entry=self.coordinator.config_entry), 2) if median is not None else None ) - return avg_result, median_result + return mean_result, median_result # Single value result (min/max functions) value = result diff --git a/custom_components/tibber_prices/sensor/core.py b/custom_components/tibber_prices/sensor/core.py index f85b910..6dcfec6 100644 --- a/custom_components/tibber_prices/sensor/core.py +++ b/custom_components/tibber_prices/sensor/core.py @@ -40,7 +40,7 @@ from custom_components.tibber_prices.entity_utils.icons import ( get_dynamic_icon, ) from custom_components.tibber_prices.utils.average import ( - calculate_next_n_hours_avg, + calculate_next_n_hours_mean, ) from custom_components.tibber_prices.utils.price import ( calculate_volatility_level, @@ -100,7 +100,7 @@ MIN_HOURS_FOR_LATER_HALF = 3 # Minimum hours needed to calculate later half ave class TibberPricesSensor(TibberPricesEntity, RestoreSensor): """tibber_prices Sensor class with state restoration.""" - # Attributes excluded from recorder history + # Base attributes excluded from recorder history (shared across all sensors) # See: https://developers.home-assistant.io/docs/core/entity/#excluding-state-attributes-from-recorder-history _unrecorded_attributes = frozenset( { @@ -190,7 +190,48 @@ class TibberPricesSensor(TibberPricesEntity, RestoreSensor): """When entity is added to hass.""" await super().async_added_to_hass() + # Configure dynamic attribute exclusion for average sensors + self._configure_average_sensor_exclusions() + # Restore last state if available + await self._restore_last_state() + + # Register listeners for time-sensitive updates + self._register_update_listeners() + + # Trigger initial chart data loads as background tasks + self._trigger_chart_data_loads() + + def _configure_average_sensor_exclusions(self) -> None: + """Configure dynamic attribute exclusions for average sensors.""" + # Dynamically exclude average attribute that matches state value + # (to avoid recording the same value twice: once as state, once as attribute) + key = self.entity_description.key + if key in ( + "average_price_today", + "average_price_tomorrow", + "trailing_price_average", + "leading_price_average", + "current_hour_average_price", + "next_hour_average_price", + ) or key.startswith("next_avg_"): # Future average sensors + display_mode = self.coordinator.config_entry.options.get( + CONF_AVERAGE_SENSOR_DISPLAY, + DEFAULT_AVERAGE_SENSOR_DISPLAY, + ) + # Modify _state_info to add dynamic exclusion + if self._state_info is None: + self._state_info = {} + current_unrecorded = self._state_info.get("unrecorded_attributes", frozenset()) + # State shows median → exclude price_median from attributes + # State shows mean → exclude price_mean from attributes + if display_mode == "median": + self._state_info["unrecorded_attributes"] = current_unrecorded | {"price_median"} + else: + self._state_info["unrecorded_attributes"] = current_unrecorded | {"price_mean"} + + async def _restore_last_state(self) -> None: + """Restore last state if available.""" if ( (last_state := await self.async_get_last_state()) is not None and last_state.state not in (None, "unknown", "unavailable", "") @@ -213,6 +254,8 @@ class TibberPricesSensor(TibberPricesEntity, RestoreSensor): self._chart_metadata_response = metadata_attrs self._chart_metadata_last_update = last_state.attributes.get("last_update") + def _register_update_listeners(self) -> None: + """Register listeners for time-sensitive updates.""" # Register with coordinator for time-sensitive updates if applicable if self.entity_description.key in TIME_SENSITIVE_ENTITY_KEYS: self._time_sensitive_remove_listener = self.coordinator.async_add_time_sensitive_listener( @@ -225,6 +268,8 @@ class TibberPricesSensor(TibberPricesEntity, RestoreSensor): self._handle_minute_update ) + def _trigger_chart_data_loads(self) -> None: + """Trigger initial chart data loads as background tasks.""" # For chart_data_export, trigger initial service call as background task # (non-blocking to avoid delaying entity setup) if self.entity_description.key == "chart_data_export": @@ -521,7 +566,7 @@ class TibberPricesSensor(TibberPricesEntity, RestoreSensor): - "leading": Next 24 hours (96 intervals after current) Args: - stat_func: Function from average_utils (e.g., calculate_current_trailing_avg) + stat_func: Function from average_utils (e.g., calculate_current_trailing_mean) Returns: Price value in subunit currency units (cents/øre), or None if unavailable @@ -570,28 +615,37 @@ class TibberPricesSensor(TibberPricesEntity, RestoreSensor): def _get_next_avg_n_hours_value(self, hours: int) -> float | None: """ - Get average price for next N hours starting from next interval. + Get mean price for next N hours starting from next interval. Args: hours: Number of hours to look ahead (1, 2, 3, 4, 5, 6, 8, 12) Returns: - Average price in subunit currency units (e.g., cents), or None if unavailable + Mean or median price (based on config) in subunit currency units (e.g., cents), + or None if unavailable """ - avg_price, median_price = calculate_next_n_hours_avg(self.coordinator.data, hours, time=self.coordinator.time) - if avg_price is None: + mean_price, median_price = calculate_next_n_hours_mean(self.coordinator.data, hours, time=self.coordinator.time) + if mean_price is None: return None # Get display unit factor (100 for minor, 1 for major) factor = get_display_unit_factor(self.coordinator.config_entry) - # Store median for attributes + # Get user preference for display (mean or median) + display_pref = self.coordinator.config_entry.options.get( + CONF_AVERAGE_SENSOR_DISPLAY, DEFAULT_AVERAGE_SENSOR_DISPLAY + ) + + # Store both values for attributes + self.cached_data[f"next_avg_{hours}h_mean"] = round(mean_price * factor, 2) if median_price is not None: self.cached_data[f"next_avg_{hours}h_median"] = round(median_price * factor, 2) - # Convert from major to display currency units - return round(avg_price * factor, 2) + # Return the value chosen for state display + if display_pref == "median" and median_price is not None: + return round(median_price * factor, 2) + return round(mean_price * factor, 2) # "mean" def _get_data_timestamp(self) -> datetime | None: """ diff --git a/custom_components/tibber_prices/sensor/definitions.py b/custom_components/tibber_prices/sensor/definitions.py index b7c11a2..34bd7ee 100644 --- a/custom_components/tibber_prices/sensor/definitions.py +++ b/custom_components/tibber_prices/sensor/definitions.py @@ -454,7 +454,7 @@ WINDOW_24H_SENSORS = ( # ---------------------------------------------------------------------------- # Calculate averages and trends for upcoming time windows -FUTURE_AVG_SENSORS = ( +FUTURE_MEAN_SENSORS = ( # Default enabled: 1h-5h SensorEntityDescription( key="next_avg_1h", @@ -1031,7 +1031,7 @@ ENTITY_DESCRIPTIONS = ( *DAILY_LEVEL_SENSORS, *DAILY_RATING_SENSORS, *WINDOW_24H_SENSORS, - *FUTURE_AVG_SENSORS, + *FUTURE_MEAN_SENSORS, *FUTURE_TREND_SENSORS, *VOLATILITY_SENSORS, *BEST_PRICE_TIMING_SENSORS, diff --git a/custom_components/tibber_prices/sensor/helpers.py b/custom_components/tibber_prices/sensor/helpers.py index d015d74..6488a23 100644 --- a/custom_components/tibber_prices/sensor/helpers.py +++ b/custom_components/tibber_prices/sensor/helpers.py @@ -28,7 +28,7 @@ if TYPE_CHECKING: from custom_components.tibber_prices.const import get_display_unit_factor 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.average import calculate_mean, calculate_median from custom_components.tibber_prices.utils.price import ( aggregate_price_levels, aggregate_price_rating, @@ -38,7 +38,7 @@ if TYPE_CHECKING: from collections.abc import Callable -def aggregate_price_data( +def aggregate_average_data( window_data: list[dict], config_entry: ConfigEntry, ) -> tuple[float | None, float | None]: @@ -57,12 +57,12 @@ def aggregate_price_data( prices = [float(i["total"]) for i in window_data if "total" in i] if not prices: return None, None - # Calculate both average and median - avg = sum(prices) / len(prices) + # Calculate both mean and median + mean = calculate_mean(prices) median = calculate_median(prices) # Convert to display currency unit based on configuration factor = get_display_unit_factor(config_entry) - return round(avg * factor, 2), round(median * factor, 2) if median is not None else None + return round(mean * factor, 2), round(median * factor, 2) if median is not None else None def aggregate_level_data(window_data: list[dict]) -> str | None: @@ -135,7 +135,7 @@ def aggregate_window_data( """ # Map value types to aggregation functions aggregators: dict[str, Callable] = { - "price": lambda data: aggregate_price_data(data, config_entry)[0], # Use only average from tuple + "price": lambda data: aggregate_average_data(data, config_entry)[0], # Use only average from tuple "level": lambda data: aggregate_level_data(data), "rating": lambda data: aggregate_rating_data(data, threshold_low, threshold_high), } diff --git a/custom_components/tibber_prices/sensor/value_getters.py b/custom_components/tibber_prices/sensor/value_getters.py index f624b55..68dc09c 100644 --- a/custom_components/tibber_prices/sensor/value_getters.py +++ b/custom_components/tibber_prices/sensor/value_getters.py @@ -5,12 +5,13 @@ from __future__ import annotations from typing import TYPE_CHECKING from custom_components.tibber_prices.utils.average import ( - calculate_current_leading_avg, calculate_current_leading_max, + calculate_current_leading_mean, calculate_current_leading_min, - calculate_current_trailing_avg, calculate_current_trailing_max, + calculate_current_trailing_mean, calculate_current_trailing_min, + calculate_mean, calculate_median, ) @@ -131,14 +132,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), calculate_median(prices)), + stat_func=lambda prices: (calculate_mean(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), calculate_median(prices)), + stat_func=lambda prices: (calculate_mean(prices), calculate_median(prices)), ), # Daily aggregated level sensors "yesterday_price_level": lambda: daily_stat_calculator.get_daily_aggregated_value( @@ -163,10 +164,10 @@ def get_value_getter_mapping( # noqa: PLR0913 - needs all calculators as parame # ================================================================ # Trailing and leading average sensors "trailing_price_average": lambda: window_24h_calculator.get_24h_window_value( - stat_func=calculate_current_trailing_avg, + stat_func=calculate_current_trailing_mean, ), "leading_price_average": lambda: window_24h_calculator.get_24h_window_value( - stat_func=calculate_current_leading_avg, + stat_func=calculate_current_leading_mean, ), # Trailing and leading min/max sensors "trailing_price_min": lambda: window_24h_calculator.get_24h_window_value( diff --git a/custom_components/tibber_prices/utils/__init__.py b/custom_components/tibber_prices/utils/__init__.py index 060e125..50f0bab 100644 --- a/custom_components/tibber_prices/utils/__init__.py +++ b/custom_components/tibber_prices/utils/__init__.py @@ -17,13 +17,15 @@ For entity-specific utilities (icons, colors, attributes), see entity_utils/ pac from __future__ import annotations from .average import ( - calculate_current_leading_avg, calculate_current_leading_max, + calculate_current_leading_mean, calculate_current_leading_min, - calculate_current_trailing_avg, calculate_current_trailing_max, + calculate_current_trailing_mean, calculate_current_trailing_min, - calculate_next_n_hours_avg, + calculate_mean, + calculate_median, + calculate_next_n_hours_mean, ) from .price import ( aggregate_period_levels, @@ -44,14 +46,16 @@ __all__ = [ "aggregate_period_ratings", "aggregate_price_levels", "aggregate_price_rating", - "calculate_current_leading_avg", "calculate_current_leading_max", + "calculate_current_leading_mean", "calculate_current_leading_min", - "calculate_current_trailing_avg", "calculate_current_trailing_max", + "calculate_current_trailing_mean", "calculate_current_trailing_min", "calculate_difference_percentage", - "calculate_next_n_hours_avg", + "calculate_mean", + "calculate_median", + "calculate_next_n_hours_mean", "calculate_price_trend", "calculate_rating_level", "calculate_trailing_average_for_interval", diff --git a/custom_components/tibber_prices/utils/average.py b/custom_components/tibber_prices/utils/average.py index 6141daf..2b69ab5 100644 --- a/custom_components/tibber_prices/utils/average.py +++ b/custom_components/tibber_prices/utils/average.py @@ -35,17 +35,43 @@ def calculate_median(prices: list[float]) -> float | None: return sorted_prices[n // 2] -def calculate_trailing_24h_avg(all_prices: list[dict], interval_start: datetime) -> tuple[float | None, float | None]: +def calculate_mean(prices: list[float]) -> float: """ - Calculate trailing 24-hour average and median price for a given interval. + Calculate arithmetic mean (average) from a list of prices. + + Args: + prices: List of price values (must not be empty) + + Returns: + Mean price + + Raises: + ValueError: If prices list is empty + + """ + if not prices: + msg = "Cannot calculate mean of empty list" + raise ValueError(msg) + + return sum(prices) / len(prices) + + +def calculate_trailing_24h_mean( + all_prices: list[dict], + interval_start: datetime, + *, + time: TibberPricesTimeService, +) -> tuple[float | None, float | None]: + """ + Calculate trailing 24-hour mean and median price for a given interval. Args: all_prices: List of all price data (yesterday, today, tomorrow combined) - interval_start: Start time of the interval to calculate average for + interval_start: Start time of the interval to calculate mean for time: TibberPricesTimeService instance (required) Returns: - Tuple of (average price, median price) for the 24 hours preceding the interval, + Tuple of (mean price, median price) for the 24 hours preceding the interval, or (None, None) if no data in window """ @@ -56,34 +82,39 @@ def calculate_trailing_24h_avg(all_prices: list[dict], interval_start: datetime) # Filter prices within the 24-hour window prices_in_window = [] for price_data in all_prices: - starts_at = price_data["startsAt"] # Already datetime object in local timezone + starts_at = time.get_interval_time(price_data) if starts_at is None: continue # Include intervals that start within the window (not including the current interval's end) if window_start <= starts_at < window_end: prices_in_window.append(float(price_data["total"])) - # Calculate average and median + # Calculate mean 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 + # With negative prices, 0.0 could be misinterpreted as a real mean value if prices_in_window: - avg = sum(prices_in_window) / len(prices_in_window) + mean = calculate_mean(prices_in_window) median = calculate_median(prices_in_window) - return avg, median + return mean, median return None, None -def calculate_leading_24h_avg(all_prices: list[dict], interval_start: datetime) -> tuple[float | None, float | None]: +def calculate_leading_24h_mean( + all_prices: list[dict], + interval_start: datetime, + *, + time: TibberPricesTimeService, +) -> tuple[float | None, float | None]: """ - Calculate leading 24-hour average and median price for a given interval. + Calculate leading 24-hour mean and median price for a given interval. Args: all_prices: List of all price data (yesterday, today, tomorrow combined) - interval_start: Start time of the interval to calculate average for + interval_start: Start time of the interval to calculate mean for time: TibberPricesTimeService instance (required) Returns: - Tuple of (average price, median price) for up to 24 hours following the interval, + Tuple of (mean price, median price) for up to 24 hours following the interval, or (None, None) if no data in window """ @@ -94,77 +125,79 @@ def calculate_leading_24h_avg(all_prices: list[dict], interval_start: datetime) # Filter prices within the 24-hour window prices_in_window = [] for price_data in all_prices: - starts_at = price_data["startsAt"] # Already datetime object in local timezone + starts_at = time.get_interval_time(price_data) if starts_at is None: continue # Include intervals that start within the window if window_start <= starts_at < window_end: prices_in_window.append(float(price_data["total"])) - # Calculate average and median + # Calculate mean 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 + # With negative prices, 0.0 could be misinterpreted as a real mean value if prices_in_window: - avg = sum(prices_in_window) / len(prices_in_window) + mean = calculate_mean(prices_in_window) median = calculate_median(prices_in_window) - return avg, median + return mean, median return None, None -def calculate_current_trailing_avg( +def calculate_current_trailing_mean( coordinator_data: dict, *, time: TibberPricesTimeService, -) -> float | None: +) -> tuple[float | None, float | None]: """ - Calculate the trailing 24-hour average for the current time. + Calculate the trailing 24-hour mean and median for the current time. Args: coordinator_data: The coordinator data containing priceInfo time: TibberPricesTimeService instance (required) Returns: - Current trailing 24-hour average price, or None if unavailable + Tuple of (mean price, median price), or (None, None) if unavailable """ if not coordinator_data: - 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 now = time.now() - return calculate_trailing_24h_min(all_prices, now, time=time) + # calculate_trailing_24h_mean returns (mean, median) tuple + return calculate_trailing_24h_mean(all_prices, now, time=time) -def calculate_current_leading_avg( +def calculate_current_leading_mean( coordinator_data: dict, *, time: TibberPricesTimeService, -) -> float | None: +) -> tuple[float | None, float | None]: """ - Calculate the leading 24-hour average for the current time. + Calculate the leading 24-hour mean and median for the current time. Args: coordinator_data: The coordinator data containing priceInfo time: TibberPricesTimeService instance (required) Returns: - Current leading 24-hour average price, or None if unavailable + Tuple of (mean price, median price), or (None, None) if unavailable """ if not coordinator_data: - 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 now = time.now() - return calculate_leading_24h_min(all_prices, now, time=time) + # calculate_leading_24h_mean returns (mean, median) tuple + return calculate_leading_24h_mean(all_prices, now, time=time) def calculate_trailing_24h_min( @@ -408,11 +441,7 @@ def calculate_current_leading_min( return None now = time.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 + return calculate_leading_24h_min(all_prices, now, time=time) def calculate_current_leading_max( @@ -443,16 +472,16 @@ def calculate_current_leading_max( return calculate_leading_24h_max(all_prices, now, time=time) -def calculate_next_n_hours_avg( +def calculate_next_n_hours_mean( coordinator_data: dict, hours: int, *, time: TibberPricesTimeService, ) -> tuple[float | None, float | None]: """ - Calculate average and median price for the next N hours starting from the next interval. + Calculate mean and median price for the next N hours starting from the next interval. - This function computes the average and median of all 15-minute intervals starting from + This function computes the mean and median of all 15-minute intervals starting from the next interval (not current) up to N hours into the future. Args: @@ -461,7 +490,7 @@ def calculate_next_n_hours_avg( time: TibberPricesTimeService instance (required) Returns: - Tuple of (average price, median price) for the next N hours, + Tuple of (mean price, median price) for the next N hours, or (None, None) if insufficient data """ @@ -506,7 +535,7 @@ def calculate_next_n_hours_avg( if not prices_in_window: return None, None - # Return average and median (prefer full period, but allow graceful degradation) - avg = sum(prices_in_window) / len(prices_in_window) + # Return mean and median (prefer full period, but allow graceful degradation) + mean = calculate_mean(prices_in_window) median = calculate_median(prices_in_window) - return avg, median + return mean, median