From 51a99980df2909e4376cdde0838a2b4ebff7ec20 Mon Sep 17 00:00:00 2001 From: Julian Pawlowski <75446+jpawlowski@users.noreply.github.com> Date: Mon, 8 Dec 2025 17:53:40 +0000 Subject: [PATCH] feat(sensors)!: add configurable median/mean display for average sensors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- AGENTS.md | 12 ++- .../tibber_prices/binary_sensor/attributes.py | 8 +- .../tibber_prices/binary_sensor/core.py | 1 + .../config_flow_handlers/schemas.py | 17 +++ custom_components/tibber_prices/const.py | 2 + .../period_handlers/period_statistics.py | 25 +++-- .../coordinator/period_handlers/types.py | 3 +- .../sensor/attributes/__init__.py | 23 +++- .../sensor/attributes/daily_stat.py | 14 ++- .../tibber_prices/sensor/attributes/future.py | 19 +++- .../sensor/attributes/helpers.py | 52 +++++++++ .../sensor/attributes/interval.py | 13 +++ .../sensor/attributes/window_24h.py | 19 +++- .../sensor/calculators/daily_stat.py | 24 ++++- .../sensor/calculators/rolling_hour.py | 18 ++-- .../tibber_prices/sensor/calculators/trend.py | 2 +- .../sensor/calculators/window_24h.py | 18 +++- .../tibber_prices/sensor/core.py | 101 +++++++++++++++--- .../tibber_prices/sensor/helpers.py | 17 +-- .../tibber_prices/sensor/value_getters.py | 5 +- .../tibber_prices/translations/de.json | 18 +++- .../tibber_prices/translations/en.json | 12 ++- .../tibber_prices/translations/nb.json | 12 ++- .../tibber_prices/translations/nl.json | 12 ++- .../tibber_prices/translations/sv.json | 6 ++ .../tibber_prices/utils/average.py | 83 ++++++++++---- docs/developer/docs/recorder-optimization.md | 5 +- docs/user/docs/period-calculation.md | 3 +- 28 files changed, 447 insertions(+), 97 deletions(-) create mode 100644 custom_components/tibber_prices/sensor/attributes/helpers.py diff --git a/AGENTS.md b/AGENTS.md index 8b31bcd..ef26e84 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/custom_components/tibber_prices/binary_sensor/attributes.py b/custom_components/tibber_prices/binary_sensor/attributes.py index 576081b..4737a2a 100644 --- a/custom_components/tibber_prices/binary_sensor/attributes.py +++ b/custom_components/tibber_prices/binary_sensor/attributes.py @@ -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_%, diff --git a/custom_components/tibber_prices/binary_sensor/core.py b/custom_components/tibber_prices/binary_sensor/core.py index 1f3fc9b..6db516e 100644 --- a/custom_components/tibber_prices/binary_sensor/core.py +++ b/custom_components/tibber_prices/binary_sensor/core.py @@ -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", diff --git a/custom_components/tibber_prices/config_flow_handlers/schemas.py b/custom_components/tibber_prices/config_flow_handlers/schemas.py index 2fd8408..4d10e96 100644 --- a/custom_components/tibber_prices/config_flow_handlers/schemas.py +++ b/custom_components/tibber_prices/config_flow_handlers/schemas.py @@ -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", + ), + ), } ) diff --git a/custom_components/tibber_prices/const.py b/custom_components/tibber_prices/const.py index d4275f9..5f382cf 100644 --- a/custom_components/tibber_prices/const.py +++ b/custom_components/tibber_prices/const.py @@ -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) diff --git a/custom_components/tibber_prices/coordinator/period_handlers/period_statistics.py b/custom_components/tibber_prices/coordinator/period_handlers/period_statistics.py index 22daa5a..5617698 100644 --- a/custom_components/tibber_prices/coordinator/period_handlers/period_statistics.py +++ b/custom_components/tibber_prices/coordinator/period_handlers/period_statistics.py @@ -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"], diff --git a/custom_components/tibber_prices/coordinator/period_handlers/types.py b/custom_components/tibber_prices/coordinator/period_handlers/types.py index c8a37c0..87dc4d9 100644 --- a/custom_components/tibber_prices/coordinator/period_handlers/types.py +++ b/custom_components/tibber_prices/coordinator/period_handlers/types.py @@ -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 diff --git a/custom_components/tibber_prices/sensor/attributes/__init__.py b/custom_components/tibber_prices/sensor/attributes/__init__.py index ec1ec9c..f5de39c 100644 --- a/custom_components/tibber_prices/sensor/attributes/__init__.py +++ b/custom_components/tibber_prices/sensor/attributes/__init__.py @@ -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 diff --git a/custom_components/tibber_prices/sensor/attributes/daily_stat.py b/custom_components/tibber_prices/sensor/attributes/daily_stat.py index a39a9d5..e4a7bfc 100644 --- a/custom_components/tibber_prices/sensor/attributes/daily_stat.py +++ b/custom_components/tibber_prices/sensor/attributes/daily_stat.py @@ -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 diff --git a/custom_components/tibber_prices/sensor/attributes/future.py b/custom_components/tibber_prices/sensor/attributes/future.py index c3b2d38..e0163dd 100644 --- a/custom_components/tibber_prices/sensor/attributes/future.py +++ b/custom_components/tibber_prices/sensor/attributes/future.py @@ -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, diff --git a/custom_components/tibber_prices/sensor/attributes/helpers.py b/custom_components/tibber_prices/sensor/attributes/helpers.py new file mode 100644 index 0000000..ec40a52 --- /dev/null +++ b/custom_components/tibber_prices/sensor/attributes/helpers.py @@ -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 diff --git a/custom_components/tibber_prices/sensor/attributes/interval.py b/custom_components/tibber_prices/sensor/attributes/interval.py index 68ef90f..d11b9d3 100644 --- a/custom_components/tibber_prices/sensor/attributes/interval.py +++ b/custom_components/tibber_prices/sensor/attributes/interval.py @@ -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, diff --git a/custom_components/tibber_prices/sensor/attributes/window_24h.py b/custom_components/tibber_prices/sensor/attributes/window_24h.py index 2b805b3..dcb37a2 100644 --- a/custom_components/tibber_prices/sensor/attributes/window_24h.py +++ b/custom_components/tibber_prices/sensor/attributes/window_24h.py @@ -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, + ) diff --git a/custom_components/tibber_prices/sensor/calculators/daily_stat.py b/custom_components/tibber_prices/sensor/calculators/daily_stat.py index e54f770..939388e 100644 --- a/custom_components/tibber_prices/sensor/calculators/daily_stat.py +++ b/custom_components/tibber_prices/sensor/calculators/daily_stat.py @@ -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: diff --git a/custom_components/tibber_prices/sensor/calculators/rolling_hour.py b/custom_components/tibber_prices/sensor/calculators/rolling_hour.py index b645f37..22982ca 100644 --- a/custom_components/tibber_prices/sensor/calculators/rolling_hour.py +++ b/custom_components/tibber_prices/sensor/calculators/rolling_hour.py @@ -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), } diff --git a/custom_components/tibber_prices/sensor/calculators/trend.py b/custom_components/tibber_prices/sensor/calculators/trend.py index 0a10738..5f32d39 100644 --- a/custom_components/tibber_prices/sensor/calculators/trend.py +++ b/custom_components/tibber_prices/sensor/calculators/trend.py @@ -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 diff --git a/custom_components/tibber_prices/sensor/calculators/window_24h.py b/custom_components/tibber_prices/sensor/calculators/window_24h.py index 1c0c08c..b9ab20f 100644 --- a/custom_components/tibber_prices/sensor/calculators/window_24h.py +++ b/custom_components/tibber_prices/sensor/calculators/window_24h.py @@ -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 diff --git a/custom_components/tibber_prices/sensor/core.py b/custom_components/tibber_prices/sensor/core.py index e93fe94..864d829 100644 --- a/custom_components/tibber_prices/sensor/core.py +++ b/custom_components/tibber_prices/sensor/core.py @@ -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,19 +993,25 @@ class TibberPricesSensor(TibberPricesEntity, RestoreSensor): return self._get_chart_metadata_attributes() # Prepare cached data that attribute builders might need - cached_data = { - "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(), - "volatility_attributes": self._volatility_calculator.get_volatility_attributes(), - "last_extreme_interval": self._daily_stat_calculator.get_last_extreme_interval(), - "last_price_level": self._interval_calculator.get_last_price_level(), - "last_rating_difference": self._interval_calculator.get_last_rating_difference(), - "last_rating_level": self._interval_calculator.get_last_rating_level(), - "data_timestamp": getattr(self, "_data_timestamp", None), - "rolling_hour_level": self._get_rolling_hour_level_for_cached_data(key), - "lifecycle_calculator": self._lifecycle_calculator, # For lifecycle sensor attributes - } + # 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(), + "volatility_attributes": self._volatility_calculator.get_volatility_attributes(), + "last_extreme_interval": self._daily_stat_calculator.get_last_extreme_interval(), + "last_price_level": self._interval_calculator.get_last_price_level(), + "last_rating_difference": self._interval_calculator.get_last_rating_difference(), + "last_rating_level": self._interval_calculator.get_last_rating_level(), + "data_timestamp": getattr(self, "_data_timestamp", None), + "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: diff --git a/custom_components/tibber_prices/sensor/helpers.py b/custom_components/tibber_prices/sensor/helpers.py index b8a6470..53d7fc3 100644 --- a/custom_components/tibber_prices/sensor/helpers.py +++ b/custom_components/tibber_prices/sensor/helpers.py @@ -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), } diff --git a/custom_components/tibber_prices/sensor/value_getters.py b/custom_components/tibber_prices/sensor/value_getters.py index ed9fbfc..57027ac 100644 --- a/custom_components/tibber_prices/sensor/value_getters.py +++ b/custom_components/tibber_prices/sensor/value_getters.py @@ -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( diff --git a/custom_components/tibber_prices/translations/de.json b/custom_components/tibber_prices/translations/de.json index cec40b5..6055644 100644 --- a/custom_components/tibber_prices/translations/de.json +++ b/custom_components/tibber_prices/translations/de.json @@ -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" diff --git a/custom_components/tibber_prices/translations/en.json b/custom_components/tibber_prices/translations/en.json index 32830a7..ec5b6a0 100644 --- a/custom_components/tibber_prices/translations/en.json +++ b/custom_components/tibber_prices/translations/en.json @@ -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" diff --git a/custom_components/tibber_prices/translations/nb.json b/custom_components/tibber_prices/translations/nb.json index d583a2d..dc4b05a 100644 --- a/custom_components/tibber_prices/translations/nb.json +++ b/custom_components/tibber_prices/translations/nb.json @@ -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" diff --git a/custom_components/tibber_prices/translations/nl.json b/custom_components/tibber_prices/translations/nl.json index 838e146..d469f1b 100644 --- a/custom_components/tibber_prices/translations/nl.json +++ b/custom_components/tibber_prices/translations/nl.json @@ -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" diff --git a/custom_components/tibber_prices/translations/sv.json b/custom_components/tibber_prices/translations/sv.json index 4797d44..276ea1e 100644 --- a/custom_components/tibber_prices/translations/sv.json +++ b/custom_components/tibber_prices/translations/sv.json @@ -1118,6 +1118,12 @@ "expensive": "Dyrt", "very_expensive": "Mycket dyrt" } + }, + "average_sensor_display": { + "options": { + "median": "Median", + "mean": "Aritmetiskt medelvärde" + } } }, "title": "Tibber Prisinformation & Betyg" diff --git a/custom_components/tibber_prices/utils/average.py b/custom_components/tibber_prices/utils/average.py index d298259..6141daf 100644 --- a/custom_components/tibber_prices/utils/average.py +++ b/custom_components/tibber_prices/utils/average.py @@ -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 diff --git a/docs/developer/docs/recorder-optimization.md b/docs/developer/docs/recorder-optimization.md index 4f21cda..a40d78f 100644 --- a/docs/developer/docs/recorder-optimization.md +++ b/docs/developer/docs/recorder-optimization.md @@ -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) diff --git a/docs/user/docs/period-calculation.md b/docs/user/docs/period-calculation.md index 8b86fc7..fd0fec9 100644 --- a/docs/user/docs/period-calculation.md +++ b/docs/user/docs/period-calculation.md @@ -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):