fix(get_chartdata): use proper statistics.median for chart metadata

_calculate_metadata()'s inline calc_stats() computed the median as a
naive sorted(data)[len(data) // 2], which returns the upper-middle
value for even-length datasets instead of averaging the two middle
values. A full day is always an even interval count (96 quarter-hours
or 24 hours), so this silently overstated "median" and
"median_position" in nearly every price_stats block of the
get_chartdata response. The rest of the codebase already has a
correct calculate_median() in utils/average.py; this was an isolated
inline duplicate with the bug.

Impact: get_chartdata's price_stats.combined/dayN.median and
median_position now report the statistically correct median for
chart annotations and metadata-driven dashboards.
This commit is contained in:
Julian Pawlowski 2026-07-04 18:23:25 +00:00
parent 1b207f0db1
commit 13b9870f45
2 changed files with 84 additions and 1 deletions

View file

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

View file

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