diff --git a/custom_components/tibber_prices/services/get_chartdata.py b/custom_components/tibber_prices/services/get_chartdata.py index 3a45773..bc11ff9 100644 --- a/custom_components/tibber_prices/services/get_chartdata.py +++ b/custom_components/tibber_prices/services/get_chartdata.py @@ -24,6 +24,7 @@ from __future__ import annotations from datetime import datetime, timedelta import math import re +import statistics from typing import TYPE_CHECKING, Any, Final import voluptuous as vol @@ -174,7 +175,12 @@ def _calculate_metadata( min_val = min(data) max_val = max(data) mean_val = sum(data) / len(data) - median_val = sorted(data)[len(data) // 2] + # CRITICAL: statistics.median() averages the two middle values for + # even-length data (the common case: a full day is always an even + # number of 15min/hourly intervals). A naive sorted(data)[len//2] + # would instead return the upper-middle value, silently overstating + # the median for every "combined"/per-day stat block. + median_val = statistics.median(data) # Calculate mean_position and median_position (0-1 scale) price_range = max_val - min_val diff --git a/tests/services/test_get_chartdata_metadata.py b/tests/services/test_get_chartdata_metadata.py new file mode 100644 index 0000000..a7277bf --- /dev/null +++ b/tests/services/test_get_chartdata_metadata.py @@ -0,0 +1,77 @@ +"""Tests for get_chartdata metadata statistics calculation.""" + +from __future__ import annotations + +from datetime import UTC, datetime, timedelta + +from custom_components.tibber_prices.services.get_chartdata import _calculate_metadata + + +def _make_chart_data(prices: list[float], start: datetime | None = None) -> list[dict]: + """Build minimal chart_data entries (start_time + price) for metadata calc.""" + base = start or datetime(2026, 1, 1, 0, 0, tzinfo=UTC) + return [ + { + "start_time": (base + timedelta(minutes=15 * i)).isoformat(), + "price_per_kwh": price, + } + for i, price in enumerate(prices) + ] + + +class TestCalculateMetadataMedian: + """Regression coverage: median must use proper statistics.median, not naive indexing. + + A naive `sorted(data)[len(data)//2]` returns the upper-middle value for + even-length datasets instead of averaging the two middle values. Since a + full day always has an even interval count (96 quarter-hours, 24 hours), + this bug silently affected nearly every "combined"/per-day median in the + chartdata metadata response. + """ + + def test_median_for_even_length_dataset_is_averaged(self) -> None: + """4 known prices: naive impl would report 30.0, correct median is 25.0.""" + chart_data = _make_chart_data([10.0, 20.0, 30.0, 40.0]) + + metadata = _calculate_metadata( + chart_data=chart_data, + price_field="price_per_kwh", + start_time_field="start_time", + currency="EUR", + resolution="interval", + subunit_currency=False, + ) + + assert metadata["price_stats"]["combined"]["median"] == 25.0 + + def test_median_for_odd_length_dataset_is_middle_value(self) -> None: + """3 known prices: median is simply the middle value.""" + chart_data = _make_chart_data([10.0, 20.0, 30.0]) + + metadata = _calculate_metadata( + chart_data=chart_data, + price_field="price_per_kwh", + start_time_field="start_time", + currency="EUR", + resolution="interval", + subunit_currency=False, + ) + + assert metadata["price_stats"]["combined"]["median"] == 20.0 + + def test_median_position_reflects_corrected_median(self) -> None: + """median_position must be derived from the corrected median, not the naive one.""" + chart_data = _make_chart_data([10.0, 20.0, 30.0, 40.0]) + + metadata = _calculate_metadata( + chart_data=chart_data, + price_field="price_per_kwh", + start_time_field="start_time", + currency="EUR", + resolution="interval", + subunit_currency=False, + ) + + combined = metadata["price_stats"]["combined"] + # median=25.0, min=10.0, max=40.0 -> position = (25-10)/(40-10) = 0.5 + assert combined["median_position"] == 0.5