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:
Julian Pawlowski 2025-12-01 11:14:27 +00:00
parent 49628f3394
commit f70ac9cff6
4 changed files with 99 additions and 41 deletions

View file

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

View file

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

View file

@ -0,0 +1 @@
"""Tests for services package."""

View file

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