mirror of
https://github.com/jpawlowski/hass.tibber_prices.git
synced 2026-07-27 17:26:48 +00:00
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:
parent
1b207f0db1
commit
13b9870f45
2 changed files with 84 additions and 1 deletions
|
|
@ -24,6 +24,7 @@ from __future__ import annotations
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
import math
|
import math
|
||||||
import re
|
import re
|
||||||
|
import statistics
|
||||||
from typing import TYPE_CHECKING, Any, Final
|
from typing import TYPE_CHECKING, Any, Final
|
||||||
|
|
||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
|
|
@ -174,7 +175,12 @@ def _calculate_metadata(
|
||||||
min_val = min(data)
|
min_val = min(data)
|
||||||
max_val = max(data)
|
max_val = max(data)
|
||||||
mean_val = sum(data) / len(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)
|
# Calculate mean_position and median_position (0-1 scale)
|
||||||
price_range = max_val - min_val
|
price_range = max_val - min_val
|
||||||
|
|
|
||||||
77
tests/services/test_get_chartdata_metadata.py
Normal file
77
tests/services/test_get_chartdata_metadata.py
Normal 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
|
||||||
Loading…
Reference in a new issue