diff --git a/custom_components/tibber_prices/services/formatters.py b/custom_components/tibber_prices/services/formatters.py index 76d26d7..ba25c95 100644 --- a/custom_components/tibber_prices/services/formatters.py +++ b/custom_components/tibber_prices/services/formatters.py @@ -215,6 +215,7 @@ def get_period_data( # noqa: PLR0913, PLR0912, PLR0915 level_field: str, rating_level_field: str, data_key: str, + insert_nulls: str, add_trailing_null: bool, ) -> dict[str, Any]: """ @@ -243,6 +244,7 @@ def get_period_data( # noqa: PLR0913, PLR0912, PLR0915 level_field: Custom name for level field rating_level_field: Custom name for rating_level field data_key: Top-level key name in response + insert_nulls: NULL insertion mode ('none', 'segments', 'all') add_trailing_null: Whether to add trailing null point Returns: @@ -327,7 +329,12 @@ def get_period_data( # noqa: PLR0913, PLR0912, PLR0915 chart_data.append(data_point) else: # array_of_arrays - # For array_of_arrays, include: [start, price_avg] + # For array_of_arrays, include 2-3 points per period depending on insert_nulls: + # Always: + # 1. Start time with price (begin period) + # 2. End time with price (hold price until end) + # If insert_nulls='segments' or 'all': + # 3. End time with NULL (cleanly terminate segment for ApexCharts) price_avg = period.get("price_avg", 0.0) # Convert to major currency unless minor_currency=True if not minor_currency: @@ -335,10 +342,23 @@ def get_period_data( # noqa: PLR0913, PLR0912, PLR0915 if round_decimals is not None: price_avg = round(price_avg, round_decimals) start = period["start"] + end = period.get("end") start_serialized = start.isoformat() if hasattr(start, "isoformat") else start - chart_data.append([start_serialized, price_avg]) + end_serialized = end.isoformat() if end and hasattr(end, "isoformat") else end - # Add trailing null point if requested + # Add data points per period + chart_data.append([start_serialized, price_avg]) # 1. Start with price + if end_serialized: + chart_data.append([end_serialized, price_avg]) # 2. End with price (hold level) + # 3. Add NULL terminator only if insert_nulls is enabled + if insert_nulls in ("segments", "all"): + chart_data.append([end_serialized, None]) # 3. End with NULL (terminate segment) + + # Add trailing null point if requested (independent of insert_nulls) + # This adds an additional NULL at the end of the entire data series. + # If both insert_nulls and add_trailing_null are enabled, you get: + # - NULL terminator after each period (from insert_nulls) + # - Additional NULL at the very end (from add_trailing_null) if add_trailing_null and chart_data: if output_format == "array_of_objects": null_point = {start_time_field: None, end_time_field: None} diff --git a/custom_components/tibber_prices/services/get_apexcharts_yaml.py b/custom_components/tibber_prices/services/get_apexcharts_yaml.py index b8fe515..7b049f5 100644 --- a/custom_components/tibber_prices/services/get_apexcharts_yaml.py +++ b/custom_components/tibber_prices/services/get_apexcharts_yaml.py @@ -284,6 +284,8 @@ async def handle_apexcharts_yaml(call: ServiceCall) -> dict[str, Any]: # noqa: # Conditionally include day parameter (omit for rolling window mode) day_param = f"day: ['{day}'], " if day else "" + # Store original prices for tooltip, but map to 1 for full-height overlay + # We use a custom tooltip formatter to show the real price best_price_generator = ( f"const response = await hass.callWS({{ " f"type: 'call_service', " @@ -292,8 +294,13 @@ async def handle_apexcharts_yaml(call: ServiceCall) -> dict[str, Any]: # noqa: f"return_response: true, " f"service_data: {{ entry_id: '{entry_id}', {day_param}" f"period_filter: 'best_price', " - f"output_format: 'array_of_arrays', minor_currency: true }} }}); " - f"return response.response.data.map(point => [point[0], 1]);" + f"output_format: 'array_of_arrays', insert_nulls: 'segments', minor_currency: true }} }}); " + f"const originalData = response.response.data; " + f"return originalData.map((point, i) => {{ " + f"const result = [point[0], point[1] === null ? null : 1]; " + f"result.originalPrice = point[1]; " + f"return result; " + f"}});" ) # Use first entity from entity_map (reuse existing entity to avoid extra header entries) @@ -305,7 +312,7 @@ async def handle_apexcharts_yaml(call: ServiceCall) -> dict[str, Any]: # noqa: "name": best_price_name, "type": "area", "color": "rgba(46, 204, 113, 0.2)", # Semi-transparent green - "yaxis_id": "highlight", + "yaxis_id": "highlight", # Use separate Y-axis (0-1) for full-height overlay "show": {"legend_value": False, "in_header": False, "in_legend": False}, "data_generator": best_price_generator, "stroke_width": 0, diff --git a/custom_components/tibber_prices/services/get_chartdata.py b/custom_components/tibber_prices/services/get_chartdata.py index d078836..eadf5ea 100644 --- a/custom_components/tibber_prices/services/get_chartdata.py +++ b/custom_components/tibber_prices/services/get_chartdata.py @@ -213,6 +213,7 @@ async def handle_chartdata(call: ServiceCall) -> dict[str, Any]: # noqa: PLR091 level_field=level_field, rating_level_field=rating_level_field, data_key=data_key, + insert_nulls=insert_nulls, add_trailing_null=add_trailing_null, ) diff --git a/tests/services/test_period_data_format.py b/tests/services/test_period_data_format.py new file mode 100644 index 0000000..a96b81b --- /dev/null +++ b/tests/services/test_period_data_format.py @@ -0,0 +1,351 @@ +"""Test period data formatting for ApexCharts visualization.""" + +from datetime import UTC, datetime + + +def test_period_array_of_arrays_with_insert_nulls() -> None: + """ + Test that period data generates 3 points per period when insert_nulls='segments'. + + For ApexCharts to correctly display periods as continuous blocks: + 1. Start time with price - Begin the period + 2. End time with price - Hold the price level until end + 3. End time with NULL - Cleanly terminate the segment (only with insert_nulls) + """ + # Simulate a period from formatters.get_period_data() + period = { + "start": datetime(2025, 12, 3, 10, 0, tzinfo=UTC), + "end": datetime(2025, 12, 3, 12, 0, tzinfo=UTC), + "price_avg": 1250, # Stored in minor units (12.50 EUR/ct) + "level": "CHEAP", + "rating_level": "LOW", + } + + # Test with insert_nulls='segments' (should add NULL terminator) + chart_data = [] + price_avg = period["price_avg"] + start_serialized = period["start"].isoformat() + end_serialized = period["end"].isoformat() + insert_nulls = "segments" + + chart_data.append([start_serialized, price_avg]) # 1. Start with price + chart_data.append([end_serialized, price_avg]) # 2. End with price (hold level) + # 3. Add NULL terminator only if insert_nulls is enabled + if insert_nulls in ("segments", "all"): + chart_data.append([end_serialized, None]) # 3. End with NULL (terminate segment) + + # Verify structure + assert len(chart_data) == 3, "Should generate 3 points with insert_nulls='segments'" + + # Point 1: Start with price + assert chart_data[0][0] == "2025-12-03T10:00:00+00:00" + assert chart_data[0][1] == 1250 + + # Point 2: End with price (holds level) + assert chart_data[1][0] == "2025-12-03T12:00:00+00:00" + assert chart_data[1][1] == 1250 + + # Point 3: End with NULL (terminates segment) + assert chart_data[2][0] == "2025-12-03T12:00:00+00:00" + assert chart_data[2][1] is None + + +def test_period_array_of_arrays_without_insert_nulls() -> None: + """ + Test that period data generates 2 points per period when insert_nulls='none'. + + Without NULL insertion, we only get: + 1. Start time with price + 2. End time with price + """ + period = { + "start": datetime(2025, 12, 3, 10, 0, tzinfo=UTC), + "end": datetime(2025, 12, 3, 12, 0, tzinfo=UTC), + "price_avg": 1250, + } + + # Test with insert_nulls='none' (should NOT add NULL terminator) + chart_data = [] + price_avg = period["price_avg"] + start_serialized = period["start"].isoformat() + end_serialized = period["end"].isoformat() + insert_nulls = "none" + + chart_data.append([start_serialized, price_avg]) + chart_data.append([end_serialized, price_avg]) + if insert_nulls in ("segments", "all"): + chart_data.append([end_serialized, None]) + + # Verify structure: Only 2 points without NULL terminator + assert len(chart_data) == 2, "Should generate 2 points with insert_nulls='none'" + assert chart_data[0][1] == 1250 + assert chart_data[1][1] == 1250 + + +def test_multiple_periods_separated_by_nulls() -> None: + """ + Test that multiple periods are properly separated by NULL points with insert_nulls enabled. + + This ensures gaps between periods are visualized correctly in ApexCharts. + """ + periods = [ + { + "start": datetime(2025, 12, 3, 10, 0, tzinfo=UTC), + "end": datetime(2025, 12, 3, 12, 0, tzinfo=UTC), + "price_avg": 1250, + }, + { + "start": datetime(2025, 12, 3, 15, 0, tzinfo=UTC), + "end": datetime(2025, 12, 3, 17, 0, tzinfo=UTC), + "price_avg": 1850, + }, + ] + + chart_data = [] + insert_nulls = "segments" + for period in periods: + price_avg = period["price_avg"] + start_serialized = period["start"].isoformat() + end_serialized = period["end"].isoformat() + + chart_data.append([start_serialized, price_avg]) + chart_data.append([end_serialized, price_avg]) + if insert_nulls in ("segments", "all"): + chart_data.append([end_serialized, None]) + + # Verify structure: 2 periods x 3 points = 6 total points (with insert_nulls) + assert len(chart_data) == 6, "Should generate 6 points for 2 periods with insert_nulls" + + # Period 1 ends with NULL + assert chart_data[2][1] is None + + # Period 2 starts + assert chart_data[3][0] == "2025-12-03T15:00:00+00:00" + assert chart_data[3][1] == 1850 + + # Period 2 ends with NULL + assert chart_data[5][1] is None + + +def test_multiple_periods_without_nulls() -> None: + """ + Test that multiple periods without insert_nulls generate continuous data. + + Without NULL separators, periods connect directly (may be desired for some chart types). + """ + periods = [ + { + "start": datetime(2025, 12, 3, 10, 0, tzinfo=UTC), + "end": datetime(2025, 12, 3, 12, 0, tzinfo=UTC), + "price_avg": 1250, + }, + { + "start": datetime(2025, 12, 3, 15, 0, tzinfo=UTC), + "end": datetime(2025, 12, 3, 17, 0, tzinfo=UTC), + "price_avg": 1850, + }, + ] + + chart_data = [] + insert_nulls = "none" + for period in periods: + price_avg = period["price_avg"] + start_serialized = period["start"].isoformat() + end_serialized = period["end"].isoformat() + + chart_data.append([start_serialized, price_avg]) + chart_data.append([end_serialized, price_avg]) + if insert_nulls in ("segments", "all"): + chart_data.append([end_serialized, None]) + + # Verify structure: 2 periods x 2 points = 4 total points (without insert_nulls) + assert len(chart_data) == 4, "Should generate 4 points for 2 periods without insert_nulls" + + # No NULL separators + assert all(point[1] is not None for point in chart_data) + + +def test_period_currency_conversion() -> None: + """ + Test that period prices are correctly converted between major/minor currency. + + Period prices are stored in minor units (ct/øre) in coordinator data. + """ + period = { + "start": datetime(2025, 12, 3, 10, 0, tzinfo=UTC), + "end": datetime(2025, 12, 3, 12, 0, tzinfo=UTC), + "price_avg": 1250, # 12.50 ct/øre + } + + # Test 1: Keep minor currency (for ApexCharts internal use) + price_minor = period["price_avg"] + assert price_minor == 1250, "Should keep minor units" + + # Test 2: Convert to major currency (for display) + price_major = period["price_avg"] / 100 + assert price_major == 12.50, "Should convert to major units (EUR)" + + +def test_period_with_missing_end_time() -> None: + """ + Test handling of periods without end time (incomplete period). + + If a period has no end time, we should only add the start point. + """ + period = { + "start": datetime(2025, 12, 3, 10, 0, tzinfo=UTC), + "end": None, # No end time + "price_avg": 1250, + } + + chart_data = [] + price_avg = period["price_avg"] + start_serialized = period["start"].isoformat() + end = period.get("end") + end_serialized = end.isoformat() if end else None + insert_nulls = "segments" + + # Add start point + chart_data.append([start_serialized, price_avg]) + + # Only add end points if end_serialized exists + if end_serialized: + chart_data.append([end_serialized, price_avg]) + if insert_nulls in ("segments", "all"): + chart_data.append([end_serialized, None]) + + # Verify: Only 1 point (start) for incomplete period + assert len(chart_data) == 1, "Should only have start point for incomplete period" + assert chart_data[0][1] == 1250 + + +def test_apexcharts_mapping_preserves_structure() -> None: + """ + Test that ApexCharts .map() transformation preserves the 3-point structure. + + The ApexCharts data_generator uses: .map(point => [point[0], 1]) + This should preserve all 3 points but replace price with 1 (for overlay). + """ + # Simulate period data (3 points per period with insert_nulls='segments') + period_data = [ + ["2025-12-03T10:00:00+00:00", 1250], # Start with price + ["2025-12-03T12:00:00+00:00", 1250], # End with price + ["2025-12-03T12:00:00+00:00", None], # End with NULL + ] + + # Simulate ApexCharts mapping: [timestamp, 1] for overlay + mapped_data = [[point[0], 1 if point[1] is not None else None] for point in period_data] + + # Verify structure is preserved + assert len(mapped_data) == 3, "Should preserve all 3 points" + assert mapped_data[0] == ["2025-12-03T10:00:00+00:00", 1] # Start + assert mapped_data[1] == ["2025-12-03T12:00:00+00:00", 1] # End (hold) + assert mapped_data[2] == ["2025-12-03T12:00:00+00:00", None] # End (terminate) + + +def test_insert_nulls_all_mode() -> None: + """ + Test that insert_nulls='all' also adds NULL terminators. + + The 'all' mode should behave the same as 'segments' for period data. + """ + period = { + "start": datetime(2025, 12, 3, 10, 0, tzinfo=UTC), + "end": datetime(2025, 12, 3, 12, 0, tzinfo=UTC), + "price_avg": 1250, + } + + chart_data = [] + price_avg = period["price_avg"] + start_serialized = period["start"].isoformat() + end_serialized = period["end"].isoformat() + insert_nulls = "all" + + chart_data.append([start_serialized, price_avg]) + chart_data.append([end_serialized, price_avg]) + if insert_nulls in ("segments", "all"): + chart_data.append([end_serialized, None]) + + # Verify: 3 points with insert_nulls='all' + assert len(chart_data) == 3, "Should generate 3 points with insert_nulls='all'" + assert chart_data[2][1] is None + + +def test_insert_nulls_and_add_trailing_null_both_enabled() -> None: + """ + Test that both insert_nulls and add_trailing_null work together correctly. + + When both are enabled, you should get: + - NULL terminator after each period (from insert_nulls) + - Additional NULL at the very end (from add_trailing_null) + + This results in TWO NULL points at the end: one for the last period, one trailing. + """ + periods = [ + { + "start": datetime(2025, 12, 3, 10, 0, tzinfo=UTC), + "end": datetime(2025, 12, 3, 12, 0, tzinfo=UTC), + "price_avg": 1250, + }, + ] + + chart_data = [] + insert_nulls = "segments" + add_trailing_null = True + + for period in periods: + price_avg = period["price_avg"] + start_serialized = period["start"].isoformat() + end_serialized = period["end"].isoformat() + + chart_data.append([start_serialized, price_avg]) + chart_data.append([end_serialized, price_avg]) + if insert_nulls in ("segments", "all"): + chart_data.append([end_serialized, None]) + + # Add trailing null + if add_trailing_null: + chart_data.append([None, None]) + + # Verify: 3 points (period) + 1 trailing = 4 total + assert len(chart_data) == 4, "Should have 4 points with both insert_nulls and add_trailing_null" + + # Last period's NULL terminator + assert chart_data[2][0] == "2025-12-03T12:00:00+00:00" + assert chart_data[2][1] is None + + # Trailing NULL (completely null) + assert chart_data[3][0] is None + assert chart_data[3][1] is None + + +def test_neither_insert_nulls_nor_add_trailing_null() -> None: + """ + Test that when both insert_nulls='none' and add_trailing_null=False, no NULLs are added. + + This gives clean period data without any NULL separators. + """ + period = { + "start": datetime(2025, 12, 3, 10, 0, tzinfo=UTC), + "end": datetime(2025, 12, 3, 12, 0, tzinfo=UTC), + "price_avg": 1250, + } + + chart_data = [] + price_avg = period["price_avg"] + start_serialized = period["start"].isoformat() + end_serialized = period["end"].isoformat() + insert_nulls = "none" + add_trailing_null = False + + chart_data.append([start_serialized, price_avg]) + chart_data.append([end_serialized, price_avg]) + if insert_nulls in ("segments", "all"): + chart_data.append([end_serialized, None]) + + if add_trailing_null: + chart_data.append([None, None]) + + # Verify: Only 2 points (start, end) without any NULLs + assert len(chart_data) == 2, "Should have 2 points without NULL insertion" + assert all(point[1] is not None for point in chart_data), "No NULL values should be present"