mirror of
https://github.com/jpawlowski/hass.tibber_prices.git
synced 2026-03-29 21:03:40 +00:00
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:
parent
1b22ce3f2a
commit
2f36c73c18
10 changed files with 183 additions and 34 deletions
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;"
|
||||
|
|
|
|||
|
|
@ -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".
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
|||
Loading…
Reference in a new issue