mirror of
https://github.com/jpawlowski/hass.tibber_prices.git
synced 2026-03-30 05:13:40 +00:00
feat(services): improve ApexCharts segment visualization and fix header display
Simplifies the connect_segments implementation to use a unified bridge-point
approach for all price transitions (up/down/same). Previously used
direction-dependent logic (hold vs connect points) which was unnecessarily
complex.
Changes:
- get_chartdata.py: Bridge points now always use next interval's price at
boundary timestamp, creating smooth visual connection between segments
- get_chartdata.py: Trailing NULL removal now conditional on insert_nulls mode
('segments' removes for header fix, 'all' preserves intentional gaps)
- get_apexcharts_yaml.py: Enable connect_segments by default, activate
show_states for header min/max display
- get_apexcharts_yaml.py: Remove extrema series (not compatible with
data_generator approach - ApexCharts requires entity time-series data)
- tests: Move test_connect_segments.py to tests/services/ to mirror source
structure
Impact: ApexCharts cards now show clean visual connections between price level
segments with proper header statistics display. Trailing NULLs no longer cause
"N/A" in headers for filtered data. Test organization improved for
maintainability.
This commit is contained in:
parent
49628f3394
commit
f70ac9cff6
4 changed files with 99 additions and 41 deletions
|
|
@ -138,11 +138,11 @@ async def handle_apexcharts_yaml(call: ServiceCall) -> dict[str, Any]:
|
||||||
f"service: 'get_chartdata', "
|
f"service: 'get_chartdata', "
|
||||||
f"return_response: true, "
|
f"return_response: true, "
|
||||||
f"service_data: {{ entry_id: '{entry_id}', day: ['{day}'], {filter_param}, "
|
f"service_data: {{ entry_id: '{entry_id}', day: ['{day}'], {filter_param}, "
|
||||||
f"output_format: 'array_of_arrays', insert_nulls: 'segments', minor_currency: true }} }}); "
|
f"output_format: 'array_of_arrays', insert_nulls: 'segments', minor_currency: true, "
|
||||||
|
f"connect_segments: true }} }}); "
|
||||||
f"return response.response.data;"
|
f"return response.response.data;"
|
||||||
)
|
)
|
||||||
# Only show extremas for HIGH and LOW levels (not NORMAL)
|
# All series use same configuration (no extremas on data_generator series)
|
||||||
show_extremas = level_key != "NORMAL"
|
|
||||||
series.append(
|
series.append(
|
||||||
{
|
{
|
||||||
"entity": sample_entity or "sensor.tibber_prices",
|
"entity": sample_entity or "sensor.tibber_prices",
|
||||||
|
|
@ -150,12 +150,16 @@ async def handle_apexcharts_yaml(call: ServiceCall) -> dict[str, Any]:
|
||||||
"type": "area",
|
"type": "area",
|
||||||
"color": color,
|
"color": color,
|
||||||
"yaxis_id": "price",
|
"yaxis_id": "price",
|
||||||
"show": {"extremas": show_extremas, "legend_value": False},
|
"show": {"legend_value": False},
|
||||||
"data_generator": data_generator,
|
"data_generator": data_generator,
|
||||||
"stroke_width": 1,
|
"stroke_width": 1,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Note: Extrema markers don't work with data_generator approach
|
||||||
|
# ApexCharts requires entity time-series data for extremas feature
|
||||||
|
# Min/Max sensors are single values, not time-series
|
||||||
|
|
||||||
# Get translated title based on level_type
|
# Get translated title based on level_type
|
||||||
title_key = "title_rating_level" if level_type == "rating_level" else "title_level"
|
title_key = "title_rating_level" if level_type == "rating_level" else "title_level"
|
||||||
title = get_translation(["apexcharts", title_key], user_language) or (
|
title = get_translation(["apexcharts", title_key], user_language) or (
|
||||||
|
|
@ -181,7 +185,7 @@ async def handle_apexcharts_yaml(call: ServiceCall) -> dict[str, Any]:
|
||||||
"header": {
|
"header": {
|
||||||
"show": True,
|
"show": True,
|
||||||
"title": title,
|
"title": title,
|
||||||
"show_states": False,
|
"show_states": True,
|
||||||
},
|
},
|
||||||
"apex_config": {
|
"apex_config": {
|
||||||
"chart": {
|
"chart": {
|
||||||
|
|
|
||||||
|
|
@ -380,44 +380,32 @@ async def handle_chartdata(call: ServiceCall) -> dict[str, Any]: # noqa: PLR091
|
||||||
)
|
)
|
||||||
|
|
||||||
if connect_segments and next_price is not None:
|
if connect_segments and next_price is not None:
|
||||||
# Connect segments visually by adding transition points
|
# Connect segments visually by adding bridge point + NULL
|
||||||
# Convert next price for comparison and use
|
# Bridge point: extends current series to boundary with next price
|
||||||
|
# NULL point: stops series so it doesn't continue into next segment
|
||||||
|
|
||||||
converted_next_price = (
|
converted_next_price = (
|
||||||
round(next_price * 100, 2) if minor_currency else round(next_price, 4)
|
round(next_price * 100, 2) if minor_currency else round(next_price, 4)
|
||||||
)
|
)
|
||||||
if round_decimals is not None:
|
if round_decimals is not None:
|
||||||
converted_next_price = round(converted_next_price, round_decimals)
|
converted_next_price = round(converted_next_price, round_decimals)
|
||||||
|
|
||||||
if next_price < price:
|
# 1. Bridge point: boundary with next price, still current level
|
||||||
# Price goes DOWN: Add point at end of current segment with lower price
|
# This makes the line go up/down to meet the next series
|
||||||
# This draws the line downward from current level
|
bridge_point = {
|
||||||
connect_point = {
|
start_time_field: next_start_serialized,
|
||||||
start_time_field: next_start_serialized,
|
price_field: converted_next_price,
|
||||||
price_field: converted_next_price,
|
}
|
||||||
}
|
if include_level and "level" in interval:
|
||||||
if include_level and "level" in interval:
|
bridge_point[level_field] = interval["level"]
|
||||||
connect_point[level_field] = interval["level"]
|
if include_rating_level and "rating_level" in interval:
|
||||||
if include_rating_level and "rating_level" in interval:
|
bridge_point[rating_level_field] = interval["rating_level"]
|
||||||
connect_point[rating_level_field] = interval["rating_level"]
|
if include_average and day in day_averages:
|
||||||
if include_average and day in day_averages:
|
bridge_point[average_field] = day_averages[day]
|
||||||
connect_point[average_field] = day_averages[day]
|
chart_data.append(bridge_point)
|
||||||
chart_data.append(connect_point)
|
|
||||||
else:
|
|
||||||
# Price goes UP or stays same: Add hold point with current price
|
|
||||||
# This extends the current level to the boundary before the gap
|
|
||||||
hold_point = {
|
|
||||||
start_time_field: next_start_serialized,
|
|
||||||
price_field: converted_price,
|
|
||||||
}
|
|
||||||
if include_level and "level" in interval:
|
|
||||||
hold_point[level_field] = interval["level"]
|
|
||||||
if include_rating_level and "rating_level" in interval:
|
|
||||||
hold_point[rating_level_field] = interval["rating_level"]
|
|
||||||
if include_average and day in day_averages:
|
|
||||||
hold_point[average_field] = day_averages[day]
|
|
||||||
chart_data.append(hold_point)
|
|
||||||
|
|
||||||
# Add NULL point to create gap after transition
|
# 2. NULL point: stops the current series
|
||||||
|
# Without this, ApexCharts continues drawing within the series
|
||||||
null_point = {start_time_field: next_start_serialized, price_field: None}
|
null_point = {start_time_field: next_start_serialized, price_field: None}
|
||||||
chart_data.append(null_point)
|
chart_data.append(null_point)
|
||||||
else:
|
else:
|
||||||
|
|
@ -580,11 +568,13 @@ async def handle_chartdata(call: ServiceCall) -> dict[str, Any]: # noqa: PLR091
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
# Remove trailing null values from chart_data (for proper ApexCharts header display).
|
# 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".
|
||||||
# Internal nulls at segment boundaries are preserved for gap visualization.
|
# Internal nulls at segment boundaries are preserved for gap visualization.
|
||||||
# Only trailing nulls cause issues with in_header showing "N/A".
|
if insert_nulls == "segments":
|
||||||
while chart_data and chart_data[-1].get(price_field) is None:
|
while chart_data and chart_data[-1].get(price_field) is None:
|
||||||
chart_data.pop()
|
chart_data.pop()
|
||||||
|
|
||||||
# Convert to array of arrays format if requested
|
# Convert to array of arrays format if requested
|
||||||
if output_format == "array_of_arrays":
|
if output_format == "array_of_arrays":
|
||||||
|
|
|
||||||
1
tests/services/__init__.py
Normal file
1
tests/services/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
"""Tests for services package."""
|
||||||
|
|
@ -162,7 +162,7 @@ class TestPriceConversion:
|
||||||
|
|
||||||
|
|
||||||
class TestTrailingNullRemoval:
|
class TestTrailingNullRemoval:
|
||||||
"""Test trailing null value removal for ApexCharts header display."""
|
"""Test trailing null value removal for ApexCharts header display (segments mode only)."""
|
||||||
|
|
||||||
def test_trailing_nulls_removed(self) -> None:
|
def test_trailing_nulls_removed(self) -> None:
|
||||||
"""Test that trailing null values are removed from chart_data."""
|
"""Test that trailing null values are removed from chart_data."""
|
||||||
|
|
@ -225,3 +225,66 @@ class TestTrailingNullRemoval:
|
||||||
chart_data.pop()
|
chart_data.pop()
|
||||||
|
|
||||||
assert chart_data == [], "Empty data should remain empty"
|
assert chart_data == [], "Empty data should remain empty"
|
||||||
|
|
||||||
|
|
||||||
|
class TestTrailingNullModeSpecific:
|
||||||
|
"""Test that trailing null removal respects insert_nulls mode."""
|
||||||
|
|
||||||
|
def test_segments_mode_removes_trailing_nulls(self) -> None:
|
||||||
|
"""Test that insert_nulls='segments' removes trailing nulls for ApexCharts header fix."""
|
||||||
|
price_field = "price_per_kwh"
|
||||||
|
insert_nulls = "segments"
|
||||||
|
chart_data = [
|
||||||
|
{"start_time": "2025-12-01T00:00:00", price_field: 10.0},
|
||||||
|
{"start_time": "2025-12-01T00:15:00", price_field: 12.0},
|
||||||
|
{"start_time": "2025-12-01T00:30:00", price_field: None}, # Trailing null
|
||||||
|
{"start_time": "2025-12-01T00:45:00", price_field: None}, # Trailing null
|
||||||
|
]
|
||||||
|
|
||||||
|
# Simulate the conditional trailing null removal
|
||||||
|
if insert_nulls == "segments":
|
||||||
|
while chart_data and chart_data[-1].get(price_field) is None:
|
||||||
|
chart_data.pop()
|
||||||
|
|
||||||
|
assert len(chart_data) == 2, "Segments mode should remove trailing nulls"
|
||||||
|
assert chart_data[-1][price_field] == 12.0, "Last item should be last non-null price"
|
||||||
|
|
||||||
|
def test_all_mode_preserves_trailing_nulls(self) -> None:
|
||||||
|
"""Test that insert_nulls='all' preserves trailing nulls (intentional gaps)."""
|
||||||
|
price_field = "price_per_kwh"
|
||||||
|
insert_nulls = "all"
|
||||||
|
chart_data = [
|
||||||
|
{"start_time": "2025-12-01T00:00:00", price_field: 10.0},
|
||||||
|
{"start_time": "2025-12-01T00:15:00", price_field: 12.0},
|
||||||
|
{"start_time": "2025-12-01T00:30:00", price_field: None}, # Intentional gap
|
||||||
|
{"start_time": "2025-12-01T00:45:00", price_field: None}, # Intentional gap
|
||||||
|
]
|
||||||
|
|
||||||
|
original_length = len(chart_data)
|
||||||
|
|
||||||
|
# Simulate the conditional trailing null removal
|
||||||
|
if insert_nulls == "segments":
|
||||||
|
while chart_data and chart_data[-1].get(price_field) is None:
|
||||||
|
chart_data.pop()
|
||||||
|
|
||||||
|
assert len(chart_data) == original_length, "'all' mode should preserve trailing nulls"
|
||||||
|
assert chart_data[-1][price_field] is None, "Last item should remain null"
|
||||||
|
|
||||||
|
def test_none_mode_no_trailing_nulls_expected(self) -> None:
|
||||||
|
"""Test that insert_nulls='none' has no trailing nulls by design."""
|
||||||
|
price_field = "price_per_kwh"
|
||||||
|
insert_nulls = "none"
|
||||||
|
# In 'none' mode, nulls are never inserted, so no trailing nulls exist
|
||||||
|
chart_data = [
|
||||||
|
{"start_time": "2025-12-01T00:00:00", price_field: 10.0},
|
||||||
|
{"start_time": "2025-12-01T00:15:00", price_field: 12.0},
|
||||||
|
]
|
||||||
|
|
||||||
|
original_length = len(chart_data)
|
||||||
|
|
||||||
|
# Simulate the conditional trailing null removal
|
||||||
|
if insert_nulls == "segments":
|
||||||
|
while chart_data and chart_data[-1].get(price_field) is None:
|
||||||
|
chart_data.pop()
|
||||||
|
|
||||||
|
assert len(chart_data) == original_length, "'none' mode should have no nulls to remove"
|
||||||
Loading…
Reference in a new issue