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"return_response: true, "
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;"
)
# Only show extremas for HIGH and LOW levels (not NORMAL)
show_extremas = level_key != "NORMAL"
# All series use same configuration (no extremas on data_generator series)
series.append(
{
"entity": sample_entity or "sensor.tibber_prices",
@ -150,12 +150,16 @@ async def handle_apexcharts_yaml(call: ServiceCall) -> dict[str, Any]:
"type": "area",
"color": color,
"yaxis_id": "price",
"show": {"extremas": show_extremas, "legend_value": False},
"show": {"legend_value": False},
"data_generator": data_generator,
"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
title_key = "title_rating_level" if level_type == "rating_level" else "title_level"
title = get_translation(["apexcharts", title_key], user_language) or (
@ -181,7 +185,7 @@ async def handle_apexcharts_yaml(call: ServiceCall) -> dict[str, Any]:
"header": {
"show": True,
"title": title,
"show_states": False,
"show_states": True,
},
"apex_config": {
"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:
# Connect segments visually by adding transition points
# Convert next price for comparison and use
# Connect segments visually by adding bridge point + NULL
# 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 = (
round(next_price * 100, 2) if minor_currency else round(next_price, 4)
)
if round_decimals is not None:
converted_next_price = round(converted_next_price, round_decimals)
if next_price < price:
# Price goes DOWN: Add point at end of current segment with lower price
# This draws the line downward from current level
connect_point = {
start_time_field: next_start_serialized,
price_field: converted_next_price,
}
if include_level and "level" in interval:
connect_point[level_field] = interval["level"]
if include_rating_level and "rating_level" in interval:
connect_point[rating_level_field] = interval["rating_level"]
if include_average and day in day_averages:
connect_point[average_field] = day_averages[day]
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)
# 1. Bridge point: boundary with next price, still current level
# This makes the line go up/down to meet the next series
bridge_point = {
start_time_field: next_start_serialized,
price_field: converted_next_price,
}
if include_level and "level" in interval:
bridge_point[level_field] = interval["level"]
if include_rating_level and "rating_level" in interval:
bridge_point[rating_level_field] = interval["rating_level"]
if include_average and day in day_averages:
bridge_point[average_field] = day_averages[day]
chart_data.append(bridge_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}
chart_data.append(null_point)
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.
# Only trailing nulls cause issues with in_header showing "N/A".
while chart_data and chart_data[-1].get(price_field) is None:
chart_data.pop()
if insert_nulls == "segments":
while chart_data and chart_data[-1].get(price_field) is None:
chart_data.pop()
# Convert to array of arrays format if requested
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:
"""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:
"""Test that trailing null values are removed from chart_data."""
@ -225,3 +225,66 @@ class TestTrailingNullRemoval:
chart_data.pop()
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"