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.
This commit is contained in:
Julian Pawlowski 2026-01-20 15:51:34 +00:00
parent 1b22ce3f2a
commit 2f36c73c18
10 changed files with 183 additions and 34 deletions

View file

@ -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"

View file

@ -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

View file

@ -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,

View file

@ -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;"

View file

@ -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".

View file

@ -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."
}
}
},

View file

@ -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."
}
}
},

View file

@ -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."
}
}
},

View file

@ -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."
}
}
},

View file

@ -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."
}
}
},