mirror of
https://github.com/jpawlowski/hass.tibber_prices.git
synced 2026-03-29 21:03:40 +00:00
Complete terminology migration from confusing "major/minor" to clearer
"base/subunit" currency naming throughout entire codebase, translations,
documentation, tests, and services.
BREAKING CHANGES:
1. **Service API Parameters Renamed**:
- `get_chartdata`: `minor_currency` → `subunit_currency`
- `get_apexcharts_yaml`: Updated service_data references from
`minor_currency: true` to `subunit_currency: true`
- All automations/scripts using these parameters MUST be updated
2. **Configuration Option Key Changed**:
- Config entry option: Display mode setting now uses new terminology
- Internal key: `currency_display_mode` values remain "base"/"subunit"
- User-facing labels updated in all 5 languages (de, en, nb, nl, sv)
3. **Sensor Entity Key Renamed**:
- `current_interval_price_major` → `current_interval_price_base`
- Entity ID changes: `sensor.tibber_home_current_interval_price_major`
→ `sensor.tibber_home_current_interval_price_base`
- Energy Dashboard configurations MUST update entity references
4. **Function Signatures Changed**:
- `format_price_unit_major()` → `format_price_unit_base()`
- `format_price_unit_minor()` → `format_price_unit_subunit()`
- `get_price_value()`: Parameter `in_euro` deprecated in favor of
`config_entry` (backward compatible for now)
5. **Translation Keys Renamed**:
- All language files: Sensor translation key
`current_interval_price_major` → `current_interval_price_base`
- Service parameter descriptions updated in all languages
- Selector options updated: Display mode dropdown values
Changes by Category:
**Core Code (Python)**:
- const.py: Renamed all format_price_unit_*() functions, updated docstrings
- entity_utils/helpers.py: Updated get_price_value() with config-driven
conversion and backward-compatible in_euro parameter
- sensor/__init__.py: Added display mode filtering for base currency sensor
- sensor/core.py:
* Implemented suggested_display_precision property for dynamic decimal places
* Updated native_unit_of_measurement to use get_display_unit_string()
* Updated all price conversion calls to use config_entry parameter
- sensor/definitions.py: Renamed entity key and updated all
suggested_display_precision values (2 decimals for most sensors)
- sensor/calculators/*.py: Updated all price conversion calls (8 calculators)
- sensor/helpers.py: Updated aggregate_price_data() signature with config_entry
- sensor/attributes/future.py: Updated future price attributes conversion
**Services**:
- services/chartdata.py: Renamed parameter minor_currency → subunit_currency
throughout (53 occurrences), updated metadata calculation
- services/apexcharts.py: Updated service_data references in generated YAML
- services/formatters.py: Renamed parameter use_minor_currency →
use_subunit_currency in aggregate_hourly_exact() and get_period_data()
- sensor/chart_metadata.py: Updated default parameter name
**Translations (5 Languages)**:
- All /translations/*.json:
* Added new config step "display_settings" with comprehensive explanations
* Renamed current_interval_price_major → current_interval_price_base
* Updated service parameter descriptions (subunit_currency)
* Added selector.currency_display_mode.options with translated labels
- All /custom_translations/*.json:
* Renamed sensor description keys
* Updated chart_metadata usage_tips references
**Documentation**:
- docs/user/docs/actions.md: Updated parameter table and feature list
- docs/user/versioned_docs/version-v0.21.0/actions.md: Backported changes
**Tests**:
- Updated 7 test files with renamed parameters and conversion logic:
* test_connect_segments.py: Renamed minor/major to subunit/base
* test_period_data_format.py: Updated period price conversion tests
* test_avg_none_fallback.py: Fixed tuple unpacking for new return format
* test_best_price_e2e.py: Added config_entry parameter to all calls
* test_cache_validity.py: Fixed cache data structure (price_info key)
* test_coordinator_shutdown.py: Added repair_manager mock
* test_midnight_turnover.py: Added config_entry parameter
* test_peak_price_e2e.py: Added config_entry parameter, fixed price_avg → price_mean
* test_percentage_calculations.py: Added config_entry mock
**Coordinator/Period Calculation**:
- coordinator/periods.py: Added config_entry parameter to
calculate_periods_with_relaxation() calls (2 locations)
Migration Guide:
1. **Update Service Calls in Automations/Scripts**:
\`\`\`yaml
# Before:
service: tibber_prices.get_chartdata
data:
minor_currency: true
# After:
service: tibber_prices.get_chartdata
data:
subunit_currency: true
\`\`\`
2. **Update Energy Dashboard Configuration**:
- Settings → Dashboards → Energy
- Replace sensor entity:
`sensor.tibber_home_current_interval_price_major` →
`sensor.tibber_home_current_interval_price_base`
3. **Review Integration Configuration**:
- Settings → Devices & Services → Tibber Prices → Configure
- New "Currency Display Settings" step added
- Default mode depends on currency (EUR → subunit, Scandinavian → base)
Rationale:
The "major/minor" terminology was confusing and didn't clearly communicate:
- **Major** → Unclear if this means "primary" or "large value"
- **Minor** → Easily confused with "less important" rather than "smaller unit"
New terminology is precise and self-explanatory:
- **Base currency** → Standard ISO currency (€, kr, $, £)
- **Subunit currency** → Fractional unit (ct, øre, ¢, p)
This aligns with:
- International terminology (ISO 4217 standard)
- Banking/financial industry conventions
- User expectations from payment processing systems
Impact: Aligns currency terminology with international standards. Users must
update service calls, automations, and Energy Dashboard configuration after
upgrade.
Refs: User feedback session (December 2025) identified terminology confusion
211 lines
8.6 KiB
Python
211 lines
8.6 KiB
Python
"""Test Bug #8: Average functions return None instead of 0.0 when no data available."""
|
|
|
|
from datetime import UTC, datetime, timedelta
|
|
|
|
import pytest
|
|
|
|
from custom_components.tibber_prices.utils.average import (
|
|
calculate_leading_24h_avg,
|
|
calculate_trailing_24h_avg,
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def sample_prices() -> list[dict]:
|
|
"""Create sample price data for testing."""
|
|
base_time = datetime(2025, 11, 22, 12, 0, tzinfo=UTC)
|
|
return [
|
|
{"startsAt": base_time - timedelta(hours=2), "total": -10.0},
|
|
{"startsAt": base_time - timedelta(hours=1), "total": -5.0},
|
|
{"startsAt": base_time, "total": 0.0},
|
|
{"startsAt": base_time + timedelta(hours=1), "total": 5.0},
|
|
{"startsAt": base_time + timedelta(hours=2), "total": 10.0},
|
|
]
|
|
|
|
|
|
def test_trailing_avg_returns_none_when_empty() -> None:
|
|
"""
|
|
Test that calculate_trailing_24h_avg returns None when no data in window.
|
|
|
|
Bug #8: Previously returned 0.0, which with negative prices could be
|
|
misinterpreted as a real average value.
|
|
"""
|
|
interval_start = datetime(2025, 11, 22, 12, 0, tzinfo=UTC)
|
|
empty_prices: list[dict] = []
|
|
|
|
avg, _median = calculate_trailing_24h_avg(empty_prices, interval_start)
|
|
|
|
assert avg is None, "Empty price list should return (None, None), not 0.0"
|
|
assert _median is None, "Empty price list should return (None, None), not 0.0"
|
|
|
|
|
|
def test_leading_avg_returns_none_when_empty() -> None:
|
|
"""
|
|
Test that calculate_leading_24h_avg returns None when no data in window.
|
|
|
|
Bug #8: Previously returned 0.0, which with negative prices could be
|
|
misinterpreted as a real average value.
|
|
"""
|
|
interval_start = datetime(2025, 11, 22, 12, 0, tzinfo=UTC)
|
|
empty_prices: list[dict] = []
|
|
|
|
avg, _median = calculate_leading_24h_avg(empty_prices, interval_start)
|
|
|
|
assert avg is None, "Empty price list should return (None, None), not 0.0"
|
|
assert _median is None, "Empty price list should return (None, None), not 0.0"
|
|
|
|
|
|
def test_trailing_avg_returns_none_when_no_data_in_window(sample_prices: list[dict]) -> None:
|
|
"""
|
|
Test that calculate_trailing_24h_avg returns None when data exists but not in the window.
|
|
|
|
This tests the case where we have price data, but it doesn't fall within
|
|
the 24-hour trailing window for the given interval.
|
|
"""
|
|
# Sample data spans 10:00-14:00 UTC on 2025-11-22
|
|
# Set interval_start to a time where the 24h trailing window doesn't contain this data
|
|
# For example, 2 hours after the last data point
|
|
interval_start = datetime(2025, 11, 22, 16, 0, tzinfo=UTC)
|
|
|
|
avg, _median = calculate_trailing_24h_avg(sample_prices, interval_start)
|
|
|
|
# Trailing window is 16:00 - 24h = yesterday 16:00 to today 16:00
|
|
# Sample data is from 10:00-14:00, which IS in this window
|
|
assert avg is not None, "Should find data in 24h trailing window"
|
|
# Average of all sample prices: (-10 + -5 + 0 + 5 + 10) / 5 = 0.0
|
|
assert avg == pytest.approx(0.0), "Average should be 0.0"
|
|
|
|
|
|
def test_leading_avg_returns_none_when_no_data_in_window(sample_prices: list[dict]) -> None:
|
|
"""
|
|
Test that calculate_leading_24h_avg returns None when data exists but not in the window.
|
|
|
|
This tests the case where we have price data, but it doesn't fall within
|
|
the 24-hour leading window for the given interval.
|
|
"""
|
|
# Sample data spans 10:00-14:00 UTC on 2025-11-22
|
|
# Set interval_start far in the future, so 24h leading window doesn't contain the data
|
|
interval_start = datetime(2025, 11, 23, 15, 0, tzinfo=UTC)
|
|
|
|
avg, _median = calculate_leading_24h_avg(sample_prices, interval_start)
|
|
|
|
# Leading window is from 15:00 today to 15:00 tomorrow
|
|
# Sample data is from yesterday, outside this window
|
|
assert avg is None, "Should return (None, None) when no data in 24h leading window"
|
|
assert _median is None, "Should return (None, None) when no data in 24h leading window"
|
|
|
|
|
|
def test_trailing_avg_with_negative_prices_distinguishes_zero(sample_prices: list[dict]) -> None:
|
|
"""
|
|
Test that calculate_trailing_24h_avg correctly distinguishes 0.0 average from None.
|
|
|
|
Bug #8 motivation: With negative prices, we need to know if the average is
|
|
truly 0.0 (real value) or if there's no data (None).
|
|
"""
|
|
# Use base_time where we have data
|
|
interval_start = datetime(2025, 11, 22, 12, 0, tzinfo=UTC)
|
|
|
|
avg, _median = calculate_trailing_24h_avg(sample_prices, interval_start)
|
|
|
|
# Should return an actual average (negative, since we have -10, -5 in the trailing window)
|
|
assert avg is not None, "Should return average when data exists"
|
|
assert isinstance(avg, float), "Should return float, not None"
|
|
assert avg != 0.0, "With negative prices, average should not be exactly 0.0"
|
|
|
|
|
|
def test_leading_avg_with_negative_prices_distinguishes_zero(sample_prices: list[dict]) -> None:
|
|
"""
|
|
Test that calculate_leading_24h_avg correctly distinguishes 0.0 average from None.
|
|
|
|
Bug #8 motivation: With negative prices, we need to know if the average is
|
|
truly 0.0 (real value) or if there's no data (None).
|
|
"""
|
|
# Use base_time - 2h to include all sample data in leading window
|
|
interval_start = datetime(2025, 11, 22, 10, 0, tzinfo=UTC)
|
|
|
|
avg, _median = calculate_leading_24h_avg(sample_prices, interval_start)
|
|
|
|
# Should return an actual average (0.0 because average of -10, -5, 0, 5, 10 = 0.0)
|
|
assert avg is not None, "Should return average when data exists"
|
|
assert isinstance(avg, float), "Should return float, not None"
|
|
assert avg == 0.0, "Average of symmetric negative/positive prices should be 0.0"
|
|
|
|
|
|
def test_trailing_avg_with_all_negative_prices() -> None:
|
|
"""
|
|
Test calculate_trailing_24h_avg with all negative prices.
|
|
|
|
Verifies that the function correctly calculates averages when all prices
|
|
are negative (common scenario in Norway/Germany with high renewable energy).
|
|
"""
|
|
base_time = datetime(2025, 11, 22, 12, 0, tzinfo=UTC)
|
|
all_negative = [
|
|
{"startsAt": base_time - timedelta(hours=3), "total": -15.0},
|
|
{"startsAt": base_time - timedelta(hours=2), "total": -10.0},
|
|
{"startsAt": base_time - timedelta(hours=1), "total": -5.0},
|
|
]
|
|
|
|
avg, _median = calculate_trailing_24h_avg(all_negative, base_time)
|
|
|
|
assert avg is not None, "Should return average for all negative prices"
|
|
assert avg < 0, "Average should be negative"
|
|
assert avg == pytest.approx(-10.0), "Average of -15, -10, -5 should be -10.0"
|
|
|
|
|
|
def test_leading_avg_with_all_negative_prices() -> None:
|
|
"""
|
|
Test calculate_leading_24h_avg with all negative prices.
|
|
|
|
Verifies that the function correctly calculates averages when all prices
|
|
are negative (common scenario in Norway/Germany with high renewable energy).
|
|
"""
|
|
base_time = datetime(2025, 11, 22, 12, 0, tzinfo=UTC)
|
|
all_negative = [
|
|
{"startsAt": base_time, "total": -5.0},
|
|
{"startsAt": base_time + timedelta(hours=1), "total": -10.0},
|
|
{"startsAt": base_time + timedelta(hours=2), "total": -15.0},
|
|
]
|
|
|
|
avg, _median = calculate_leading_24h_avg(all_negative, base_time)
|
|
|
|
assert avg is not None, "Should return average for all negative prices"
|
|
assert avg < 0, "Average should be negative"
|
|
assert avg == pytest.approx(-10.0), "Average of -5, -10, -15 should be -10.0"
|
|
|
|
|
|
def test_trailing_avg_returns_none_with_none_timestamps() -> None:
|
|
"""
|
|
Test that calculate_trailing_24h_avg handles None timestamps gracefully.
|
|
|
|
Price data with None startsAt should be skipped, and if no valid data
|
|
remains, the function should return None.
|
|
"""
|
|
interval_start = datetime(2025, 11, 22, 12, 0, tzinfo=UTC)
|
|
prices_with_none = [
|
|
{"startsAt": None, "total": 10.0},
|
|
{"startsAt": None, "total": 20.0},
|
|
]
|
|
|
|
avg, _median = calculate_trailing_24h_avg(prices_with_none, interval_start)
|
|
|
|
assert avg is None, "Should return (None, None) when all timestamps are None"
|
|
assert _median is None, "Should return (None, None) when all timestamps are None"
|
|
|
|
|
|
def test_leading_avg_returns_none_with_none_timestamps() -> None:
|
|
"""
|
|
Test that calculate_leading_24h_avg handles None timestamps gracefully.
|
|
|
|
Price data with None startsAt should be skipped, and if no valid data
|
|
remains, the function should return None.
|
|
"""
|
|
interval_start = datetime(2025, 11, 22, 12, 0, tzinfo=UTC)
|
|
prices_with_none = [
|
|
{"startsAt": None, "total": 10.0},
|
|
{"startsAt": None, "total": 20.0},
|
|
]
|
|
|
|
avg, _median = calculate_leading_24h_avg(prices_with_none, interval_start)
|
|
|
|
assert avg is None, "Should return (None, None) when all timestamps are None"
|
|
assert _median is None, "Should return (None, None) when all timestamps are None"
|