From 2f36c73c180ff6b2672309607cb1005c3d9d48c7 Mon Sep 17 00:00:00 2001 From: Julian Pawlowski <75446+jpawlowski@users.noreply.github.com> Date: Tue, 20 Jan 2026 15:51:34 +0000 Subject: [PATCH] feat(services): add hourly resolution option for chart data services Add resolution parameter to get_chartdata and get_apexcharts_yaml services, allowing users to choose between original 15-minute intervals or aggregated hourly values for chart visualization. Implementation uses rolling 5-interval window aggregation (-2, -1, 0, +1, +2 around :00 of each hour = 60 minutes total), matching the sensor rolling hour methodology. Respects user's CONF_AVERAGE_SENSOR_DISPLAY setting for mean vs median calculation. Changes: - formatters.py: Add aggregate_to_hourly() function preserving original field names (startsAt, total, level, rating_level) for unified processing - get_chartdata.py: Pre-aggregate data before processing when resolution is 'hourly', enabling same code path for filters/insert_nulls/connect_segments - get_apexcharts_yaml.py: Add resolution parameter, pass to all 4 get_chartdata service calls in generated JavaScript - services.yaml: Add resolution field with interval/hourly selector - icons.json: Add section icons for get_apexcharts_yaml fields - translations: Add highlight_peak_price and resolution field translations for all 5 languages (en, de, sv, nb, nl) Impact: Users can now generate cleaner charts with 24 hourly data points instead of 96 quarter-hourly intervals. The unified processing approach ensures all chart features (filters, null insertion, segment connection) work identically for both resolutions. --- custom_components/tibber_prices/icons.json | 10 +- custom_components/tibber_prices/services.yaml | 10 ++ .../tibber_prices/services/formatters.py | 96 +++++++++++++++++++ .../services/get_apexcharts_yaml.py | 10 +- .../tibber_prices/services/get_chartdata.py | 51 +++++----- .../tibber_prices/translations/de.json | 8 ++ .../tibber_prices/translations/en.json | 8 ++ .../tibber_prices/translations/nb.json | 8 ++ .../tibber_prices/translations/nl.json | 8 ++ .../tibber_prices/translations/sv.json | 8 ++ 10 files changed, 183 insertions(+), 34 deletions(-) diff --git a/custom_components/tibber_prices/icons.json b/custom_components/tibber_prices/icons.json index 8db9088..4b59780 100644 --- a/custom_components/tibber_prices/icons.json +++ b/custom_components/tibber_prices/icons.json @@ -16,7 +16,15 @@ } }, "get_apexcharts_yaml": { - "service": "mdi:chart-line" + "service": "mdi:chart-line", + "sections": { + "entry_id": "mdi:identifier", + "day": "mdi:calendar-range", + "level_type": "mdi:format-list-bulleted-type", + "resolution": "mdi:timer-sand", + "highlight_best_price": "mdi:battery-charging-low", + "highlight_peak_price": "mdi:battery-alert" + } }, "refresh_user_data": { "service": "mdi:refresh" diff --git a/custom_components/tibber_prices/services.yaml b/custom_components/tibber_prices/services.yaml index fc4f655..b62736d 100644 --- a/custom_components/tibber_prices/services.yaml +++ b/custom_components/tibber_prices/services.yaml @@ -46,6 +46,16 @@ get_apexcharts_yaml: - rating_level - level translation_key: level_type + resolution: + required: false + default: interval + example: interval + selector: + select: + options: + - interval + - hourly + translation_key: resolution highlight_best_price: required: false default: true diff --git a/custom_components/tibber_prices/services/formatters.py b/custom_components/tibber_prices/services/formatters.py index 546b0f3..3db0d04 100644 --- a/custom_components/tibber_prices/services/formatters.py +++ b/custom_components/tibber_prices/services/formatters.py @@ -24,6 +24,8 @@ from datetime import datetime, time from typing import Any from custom_components.tibber_prices.const import ( + CONF_AVERAGE_SENSOR_DISPLAY, + DEFAULT_AVERAGE_SENSOR_DISPLAY, DEFAULT_PRICE_RATING_THRESHOLD_HIGH, DEFAULT_PRICE_RATING_THRESHOLD_LOW, get_translation, @@ -32,6 +34,7 @@ from custom_components.tibber_prices.coordinator.helpers import ( get_intervals_for_day_offsets, ) from custom_components.tibber_prices.sensor.helpers import aggregate_level_data, aggregate_rating_data +from custom_components.tibber_prices.utils.average import calculate_mean, calculate_median def normalize_level_filter(value: list[str] | None) -> list[str] | None: @@ -48,6 +51,99 @@ def normalize_rating_level_filter(value: list[str] | None) -> list[str] | None: return [v.upper() for v in value] +def aggregate_to_hourly( # noqa: PLR0912 + intervals: list[dict], + coordinator: Any, + threshold_low: float = DEFAULT_PRICE_RATING_THRESHOLD_LOW, + threshold_high: float = DEFAULT_PRICE_RATING_THRESHOLD_HIGH, +) -> list[dict]: + """ + Aggregate 15-minute intervals to hourly using rolling 5-interval window. + + Preserves original field names (startsAt, total, level, rating_level) so the + aggregated data can be processed by the same code path as interval data. + + Uses the same methodology as sensor rolling hour calculations: + - 5-interval window: 2 before + center + 2 after (60 minutes total) + - Center interval is at :00 of each hour + - Respects user's CONF_AVERAGE_SENSOR_DISPLAY setting (mean vs median) + + Example for 10:00 data point: + - Window includes: 09:30, 09:45, 10:00, 10:15, 10:30 + + Args: + intervals: List of 15-minute price intervals with startsAt, total, level, rating_level + coordinator: Data update coordinator instance + threshold_low: Rating level threshold (low/normal boundary) + threshold_high: Rating level threshold (normal/high boundary) + + Returns: + List of hourly data points with same structure as input (startsAt, total, level, rating_level) + + """ + if not intervals: + return [] + + # Get user's average display preference (mean or median) + average_display = coordinator.config_entry.options.get(CONF_AVERAGE_SENSOR_DISPLAY, DEFAULT_AVERAGE_SENSOR_DISPLAY) + use_median = average_display == "median" + + hourly_data = [] + + # Iterate through all intervals, only process those at :00 + for i, interval in enumerate(intervals): + start_time = interval.get("startsAt") + + if not start_time: + continue + + # Check if this is the start of an hour (:00) + if start_time.minute != 0: + continue + + # Collect 5-interval rolling window: -2, -1, 0, +1, +2 + window_prices: list[float] = [] + window_intervals: list[dict] = [] + + for offset in range(-2, 3): # -2, -1, 0, +1, +2 + target_idx = i + offset + if 0 <= target_idx < len(intervals): + target_interval = intervals[target_idx] + price = target_interval.get("total") + if price is not None: + window_prices.append(price) + window_intervals.append(target_interval) + + # Calculate aggregated price based on user preference + if window_prices: + aggregated_price = calculate_median(window_prices) if use_median else calculate_mean(window_prices) + + if aggregated_price is None: + continue + + # Build data point with original field names + data_point: dict[str, Any] = { + "startsAt": start_time, + "total": aggregated_price, + } + + # Add aggregated level + if window_intervals: + aggregated_level = aggregate_level_data(window_intervals) + if aggregated_level: + data_point["level"] = aggregated_level.upper() + + # Add aggregated rating_level + if window_intervals: + aggregated_rating = aggregate_rating_data(window_intervals, threshold_low, threshold_high) + if aggregated_rating: + data_point["rating_level"] = aggregated_rating.upper() + + hourly_data.append(data_point) + + return hourly_data + + def aggregate_hourly_exact( # noqa: PLR0913, PLR0912, PLR0915 intervals: list[dict], start_time_field: str, diff --git a/custom_components/tibber_prices/services/get_apexcharts_yaml.py b/custom_components/tibber_prices/services/get_apexcharts_yaml.py index ea01165..8a7a62a 100644 --- a/custom_components/tibber_prices/services/get_apexcharts_yaml.py +++ b/custom_components/tibber_prices/services/get_apexcharts_yaml.py @@ -63,6 +63,7 @@ APEXCHARTS_SERVICE_SCHEMA = vol.Schema( vol.Required(ATTR_ENTRY_ID): cv.string, vol.Optional("day"): vol.In(["yesterday", "today", "tomorrow", "rolling_window", "rolling_window_autozoom"]), vol.Optional("level_type", default="rating_level"): vol.In(["rating_level", "level"]), + vol.Optional("resolution", default="interval"): vol.In(["interval", "hourly"]), vol.Optional("highlight_best_price", default=True): cv.boolean, vol.Optional("highlight_peak_price", default=False): cv.boolean, } @@ -296,6 +297,7 @@ async def handle_apexcharts_yaml(call: ServiceCall) -> dict[str, Any]: # noqa: day = call.data.get("day") # Can be None (rolling window mode) level_type = call.data.get("level_type", "rating_level") + resolution = call.data.get("resolution", "interval") highlight_best_price = call.data.get("highlight_best_price", True) highlight_peak_price = call.data.get("highlight_peak_price", False) @@ -361,7 +363,7 @@ async def handle_apexcharts_yaml(call: ServiceCall) -> dict[str, Any]: # noqa: f"service: 'get_chartdata', " f"return_response: true, " f"service_data: {{ entry_id: '{entry_id}', {day_param}" - f"period_filter: 'best_price', " + f"period_filter: 'best_price', resolution: '{resolution}', " f"output_format: 'array_of_arrays', insert_nulls: 'segments', subunit_currency: {subunit_param} }} }}); " f"const originalData = response.response.data; " f"return originalData.map((point, i) => {{ " @@ -400,7 +402,7 @@ async def handle_apexcharts_yaml(call: ServiceCall) -> dict[str, Any]: # noqa: f"service: 'get_chartdata', " f"return_response: true, " f"service_data: {{ entry_id: '{entry_id}', {day_param}" - f"period_filter: 'peak_price', " + f"period_filter: 'peak_price', resolution: '{resolution}', " f"output_format: 'array_of_arrays', insert_nulls: 'segments', subunit_currency: {subunit_param} }} }}); " f"const originalData = response.response.data; " f"return originalData.map((point, i) => {{ " @@ -455,7 +457,7 @@ async def handle_apexcharts_yaml(call: ServiceCall) -> dict[str, Any]: # noqa: f"domain: 'tibber_prices', " f"service: 'get_chartdata', " f"return_response: true, " - f"service_data: {{ entry_id: '{entry_id}', {day_param}{filter_param}, " + f"service_data: {{ entry_id: '{entry_id}', {day_param}{filter_param}, resolution: '{resolution}', " f"output_format: 'array_of_arrays', insert_nulls: 'segments', subunit_currency: {subunit_param}, " f"connect_segments: true }} }}); " f"return response.response.data;" @@ -468,7 +470,7 @@ async def handle_apexcharts_yaml(call: ServiceCall) -> dict[str, Any]: # noqa: f"domain: 'tibber_prices', " f"service: 'get_chartdata', " f"return_response: true, " - f"service_data: {{ entry_id: '{entry_id}', {day_param}{filter_param}, " + f"service_data: {{ entry_id: '{entry_id}', {day_param}{filter_param}, resolution: '{resolution}', " f"output_format: 'array_of_arrays', insert_nulls: 'segments', subunit_currency: {subunit_param}, " f"connect_segments: true }} }}); " f"return response.response.data;" diff --git a/custom_components/tibber_prices/services/get_chartdata.py b/custom_components/tibber_prices/services/get_chartdata.py index c7e7ccf..499f6ff 100644 --- a/custom_components/tibber_prices/services/get_chartdata.py +++ b/custom_components/tibber_prices/services/get_chartdata.py @@ -54,7 +54,12 @@ from custom_components.tibber_prices.coordinator.helpers import ( ) from homeassistant.exceptions import ServiceValidationError -from .formatters import aggregate_hourly_exact, get_period_data, normalize_level_filter, normalize_rating_level_filter +from .formatters import ( + aggregate_to_hourly, + get_period_data, + normalize_level_filter, + normalize_rating_level_filter, +) from .helpers import get_entry_and_data, has_tomorrow_data if TYPE_CHECKING: @@ -529,6 +534,19 @@ async def handle_chartdata(call: ServiceCall) -> dict[str, Any]: # noqa: PLR091 day_offsets = [{"yesterday": -1, "today": 0, "tomorrow": 1}[day] for day in days] all_prices = get_intervals_for_day_offsets(coordinator.data, day_offsets) + # For hourly resolution, aggregate BEFORE processing + # This keeps the same data format (startsAt, total, level, rating_level) + # so all subsequent code (filters, insert_nulls, etc.) works unchanged + if resolution == "hourly": + all_prices = aggregate_to_hourly( + all_prices, + coordinator=coordinator, + threshold_low=threshold_low, + threshold_high=threshold_high, + ) + # Also update all_timestamps for insert_nulls='all' mode + all_timestamps = sorted({interval["startsAt"] for interval in all_prices if interval.get("startsAt")}) + # Helper to get day key from interval timestamp for average lookup def _get_day_key_for_interval(interval_start: Any) -> str | None: """Determine which day key (yesterday/today/tomorrow) an interval belongs to.""" @@ -537,8 +555,9 @@ async def handle_chartdata(call: ServiceCall) -> dict[str, Any]: # noqa: PLR091 # Use pre-built mapping from actual interval data (TimeService-compatible) return date_to_day_key.get(interval_start.date()) - if resolution == "interval": - # Original 15-minute intervals + # Process price data - same logic handles both interval and hourly resolution + # (hourly data was already aggregated above, but has the same format) + if resolution in ("interval", "hourly"): if insert_nulls == "all" and (level_filter or rating_level_filter): # Mode 'all': Insert NULL for all timestamps where filter doesn't match # Build a map of timestamp -> interval for quick lookup @@ -865,32 +884,6 @@ async def handle_chartdata(call: ServiceCall) -> dict[str, Any]: # noqa: PLR091 chart_data.append(data_point) - elif resolution == "hourly": - # Hourly averages (4 intervals per hour: :00, :15, :30, :45) - # Process all intervals together for hourly aggregation - chart_data.extend( - aggregate_hourly_exact( - all_prices, - start_time_field, - price_field, - coordinator=coordinator, - use_subunit_currency=subunit_currency, - round_decimals=round_decimals, - include_level=include_level, - include_rating_level=include_rating_level, - level_filter=level_filter, - rating_level_filter=rating_level_filter, - include_average=include_average, - level_field=level_field, - rating_level_field=rating_level_field, - average_field=average_field, - day_average=None, # Not used when processing all days together - threshold_low=threshold_low, - period_timestamps=period_timestamps, - threshold_high=threshold_high, - ) - ) - # Remove trailing null values ONLY for insert_nulls='segments' mode. # For 'all' mode, trailing nulls are intentional (show no-match until end of day). # For 'segments' mode, trailing nulls cause ApexCharts header to show "N/A". diff --git a/custom_components/tibber_prices/translations/de.json b/custom_components/tibber_prices/translations/de.json index c7b0d3a..982f25c 100644 --- a/custom_components/tibber_prices/translations/de.json +++ b/custom_components/tibber_prices/translations/de.json @@ -948,6 +948,14 @@ "highlight_best_price": { "name": "Bestpreis-Zeiträume hervorheben", "description": "Füge eine halbtransparente grüne Überlagerung hinzu, um die Bestpreis-Zeiträume im Diagramm hervorzuheben. Dies erleichtert die visuelle Identifizierung der optimalen Zeiten für den Energieverbrauch." + }, + "highlight_peak_price": { + "name": "Spitzenpreis-Zeiträume hervorheben", + "description": "Füge eine halbtransparente rote Überlagerung hinzu, um die Spitzenpreis-Zeiträume im Diagramm hervorzuheben. Dies erleichtert die visuelle Identifizierung der Zeiten, in denen Energie am teuersten ist." + }, + "resolution": { + "name": "Auflösung", + "description": "Zeitauflösung für die Diagrammdaten. 'interval' (Standard): Originale 15-Minuten-Intervalle (96 Punkte pro Tag). 'hourly': Aggregierte Stundenwerte mit einem rollierenden 60-Minuten-Fenster (24 Punkte pro Tag) für ein übersichtlicheres Diagramm." } } }, diff --git a/custom_components/tibber_prices/translations/en.json b/custom_components/tibber_prices/translations/en.json index 2c5a5d6..6c8b9ea 100644 --- a/custom_components/tibber_prices/translations/en.json +++ b/custom_components/tibber_prices/translations/en.json @@ -948,6 +948,14 @@ "highlight_best_price": { "name": "Highlight Best Price Periods", "description": "Add a semi-transparent green overlay to highlight the best price periods on the chart. This makes it easy to visually identify the optimal times for energy consumption." + }, + "highlight_peak_price": { + "name": "Highlight Peak Price Periods", + "description": "Add a semi-transparent red overlay to highlight the peak price periods on the chart. This makes it easy to visually identify times when energy is most expensive." + }, + "resolution": { + "name": "Resolution", + "description": "Time resolution for the chart data. 'interval' (default): Original 15-minute intervals (96 points per day). 'hourly': Aggregated hourly values using a rolling 60-minute window (24 points per day) for a cleaner, less cluttered chart." } } }, diff --git a/custom_components/tibber_prices/translations/nb.json b/custom_components/tibber_prices/translations/nb.json index 6f332c2..d3e1a7f 100644 --- a/custom_components/tibber_prices/translations/nb.json +++ b/custom_components/tibber_prices/translations/nb.json @@ -948,6 +948,14 @@ "highlight_best_price": { "name": "Fremhev beste prisperioder", "description": "Legg til et halvgjennomsiktig grønt overlegg for å fremheve de beste prisperiodene i diagrammet. Dette gjør det enkelt å visuelt identifisere de optimale tidene for energiforbruk." + }, + "highlight_peak_price": { + "name": "Fremhev høyeste prisperioder", + "description": "Legg til et halvgjennomsiktig rødt overlegg for å fremheve de høyeste prisperiodene i diagrammet. Dette gjør det enkelt å visuelt identifisere tidene når energi er dyrest." + }, + "resolution": { + "name": "Oppløsning", + "description": "Tidsoppløsning for diagramdata. 'interval' (standard): Opprinnelige 15-minutters intervaller (96 punkter per dag). 'hourly': Aggregerte timeverdier med et rullende 60-minutters vindu (24 punkter per dag) for et ryddigere og mindre rotete diagram." } } }, diff --git a/custom_components/tibber_prices/translations/nl.json b/custom_components/tibber_prices/translations/nl.json index c0f41d9..624575c 100644 --- a/custom_components/tibber_prices/translations/nl.json +++ b/custom_components/tibber_prices/translations/nl.json @@ -948,6 +948,14 @@ "highlight_best_price": { "name": "Beste prijsperiodes markeren", "description": "Voeg een halfdo0rzichtige groene overlay toe om de beste prijsperiodes in de grafiek te markeren. Dit maakt het gemakkelijk om visueel de optimale tijden voor energieverbruik te identificeren." + }, + "highlight_peak_price": { + "name": "Piekprijsperiodes markeren", + "description": "Voeg een halfdoorzichtige rode overlay toe om de piekprijsperiodes in de grafiek te markeren. Dit maakt het gemakkelijk om visueel de tijden te identificeren wanneer energie het duurst is." + }, + "resolution": { + "name": "Resolutie", + "description": "Tijdresolutie voor de grafiekdata. 'interval' (standaard): Originele 15-minutenintervallen (96 punten per dag). 'hourly': Geaggregeerde uurwaarden met een rollend 60-minutenvenster (24 punten per dag) voor een overzichtelijkere grafiek." } } }, diff --git a/custom_components/tibber_prices/translations/sv.json b/custom_components/tibber_prices/translations/sv.json index 9c301fe..bc3c4ef 100644 --- a/custom_components/tibber_prices/translations/sv.json +++ b/custom_components/tibber_prices/translations/sv.json @@ -948,6 +948,14 @@ "highlight_best_price": { "name": "Markera bästa prisperioder", "description": "Lägg till ett halvtransparent grönt överlag för att markera de bästa prisperioderna i diagrammet. Detta gör det enkelt att visuellt identifiera de optimala tiderna för energiförbrukning." + }, + "highlight_peak_price": { + "name": "Markera högsta prisperioder", + "description": "Lägg till ett halvtransparent rött överlag för att markera de högsta prisperioderna i diagrammet. Detta gör det enkelt att visuellt identifiera tiderna när energi är som dyrast." + }, + "resolution": { + "name": "Upplösning", + "description": "Tidsupplösning för diagramdata. 'interval' (standard): Ursprungliga 15-minutersintervall (96 punkter per dag). 'hourly': Aggregerade timvärden med ett rullande 60-minutersfönster (24 punkter per dag) för ett renare och mindre rörigt diagram." } } },