mirror of
https://github.com/jpawlowski/hass.tibber_prices.git
synced 2026-03-29 21:03: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"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": {
|
||||
|
|
|
|||
|
|
@ -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":
|
||||
|
|
|
|||
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:
|
||||
"""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"
|
||||
Loading…
Reference in a new issue