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
392 lines
14 KiB
Python
392 lines
14 KiB
Python
"""
|
|
End-to-End Tests for Peak Price Period Generation (Nov 2025 Bug Fix).
|
|
|
|
These tests validate that the sign convention bug fix works correctly:
|
|
- Bug: Negative flex (-20%) wasn't normalized → 100% FLEX filtering
|
|
- Fix: abs() normalization in periods.py + removed redundant condition
|
|
|
|
Test coverage matches manual testing checklist:
|
|
1. ✅ Peak periods generate (not 0)
|
|
2. ✅ FLEX filter stats reasonable (~40-50%, not 100%)
|
|
3. ✅ Relaxation succeeds at reasonable flex (not maxed at 50%)
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from unittest.mock import Mock
|
|
|
|
import pytest
|
|
|
|
from custom_components.tibber_prices.coordinator.period_handlers import (
|
|
TibberPricesPeriodConfig,
|
|
calculate_periods_with_relaxation,
|
|
)
|
|
from custom_components.tibber_prices.coordinator.time_service import (
|
|
TibberPricesTimeService,
|
|
)
|
|
from homeassistant.util import dt as dt_util
|
|
|
|
|
|
def _create_realistic_intervals() -> list[dict]:
|
|
"""
|
|
Create realistic test data matching German market Nov 22, 2025.
|
|
|
|
Pattern: Morning peak (6-9h), midday low (9-15h), evening moderate (15-24h).
|
|
Daily stats: Min=30.44ct, Avg=33.26ct, Max=36.03ct
|
|
"""
|
|
# Use CURRENT date so tests work regardless of when they run
|
|
now_local = dt_util.now()
|
|
base_time = now_local.replace(hour=0, minute=0, second=0, microsecond=0)
|
|
|
|
daily_min, daily_avg, daily_max = 0.3044, 0.3326, 0.3603
|
|
|
|
def _create_interval(hour: int, minute: int, price: float, level: str, rating: str) -> dict:
|
|
"""Create a single interval dict."""
|
|
return {
|
|
"startsAt": base_time.replace(hour=hour, minute=minute), # datetime object
|
|
"total": price,
|
|
"level": level,
|
|
"rating_level": rating,
|
|
"_original_price": price,
|
|
"trailing_avg_24h": daily_avg,
|
|
"daily_min": daily_min,
|
|
"daily_avg": daily_avg,
|
|
"daily_max": daily_max,
|
|
}
|
|
|
|
# Build all intervals as list comprehensions
|
|
intervals = []
|
|
|
|
# Overnight (00:00-06:00) - NORMAL
|
|
intervals.extend(
|
|
[_create_interval(hour, minute, 0.318, "NORMAL", "NORMAL") for hour in range(6) for minute in [0, 15, 30, 45]]
|
|
)
|
|
|
|
# Morning spike (06:00-09:00) - EXPENSIVE
|
|
intervals.extend(
|
|
[
|
|
_create_interval(
|
|
hour,
|
|
minute,
|
|
price := 0.33 + (hour - 6) * 0.01,
|
|
"EXPENSIVE" if price > 0.34 else "NORMAL",
|
|
"HIGH" if price > 0.35 else "NORMAL",
|
|
)
|
|
for hour in range(6, 9)
|
|
for minute in [0, 15, 30, 45]
|
|
]
|
|
)
|
|
|
|
# Midday low (09:00-15:00) - CHEAP
|
|
intervals.extend(
|
|
[
|
|
_create_interval(hour, minute, 0.305 + (hour - 12) * 0.002, "CHEAP", "LOW")
|
|
for hour in range(9, 15)
|
|
for minute in [0, 15, 30, 45]
|
|
]
|
|
)
|
|
|
|
# Evening moderate (15:00-24:00) - NORMAL to EXPENSIVE
|
|
intervals.extend(
|
|
[
|
|
_create_interval(
|
|
hour,
|
|
minute,
|
|
price := 0.32 + (hour - 15) * 0.005,
|
|
"EXPENSIVE" if price > 0.34 else "NORMAL",
|
|
"HIGH" if price > 0.35 else "NORMAL",
|
|
)
|
|
for hour in range(15, 24)
|
|
for minute in [0, 15, 30, 45]
|
|
]
|
|
)
|
|
|
|
return intervals
|
|
|
|
|
|
@pytest.mark.unit
|
|
@pytest.mark.freeze_time("2025-11-22 12:00:00+01:00")
|
|
class TestPeakPriceGenerationWorks:
|
|
"""Validate that peak price periods generate successfully after bug fix."""
|
|
|
|
def test_peak_periods_generate_successfully(self) -> None:
|
|
"""
|
|
✅ PRIMARY TEST: Peak periods generate (not 0 like the bug).
|
|
|
|
Bug: 192/192 intervals filtered by FLEX (100%) → 0 periods
|
|
Fix: Negative flex normalized → periods generate
|
|
"""
|
|
intervals = _create_realistic_intervals()
|
|
|
|
# Mock coordinator (minimal setup)
|
|
mock_coordinator = Mock()
|
|
mock_coordinator.config_entry = Mock()
|
|
time_service = TibberPricesTimeService(mock_coordinator)
|
|
# Mock now() to return test date
|
|
test_time = dt_util.parse_datetime("2025-11-22T12:00:00+01:00")
|
|
time_service.now = Mock(return_value=test_time)
|
|
|
|
# Create config with normalized positive flex (simulating fix)
|
|
config = TibberPricesPeriodConfig(
|
|
flex=0.20, # 20% positive (after abs() normalization)
|
|
min_distance_from_avg=5.0,
|
|
min_period_length=30,
|
|
reverse_sort=True, # Peak price mode
|
|
)
|
|
|
|
# Calculate periods with relaxation
|
|
result = calculate_periods_with_relaxation(
|
|
intervals,
|
|
config=config,
|
|
enable_relaxation=True,
|
|
min_periods=2,
|
|
max_relaxation_attempts=11,
|
|
should_show_callback=lambda _: True, # Allow all levels
|
|
time=time_service,
|
|
config_entry=mock_coordinator.config_entry,
|
|
)
|
|
|
|
periods = result.get("periods", [])
|
|
|
|
# Bug validation: periods found (not 0)
|
|
assert len(periods) > 0, "Peak periods should generate after bug fix"
|
|
assert 2 <= len(periods) <= 5, f"Expected 2-5 periods, got {len(periods)}"
|
|
|
|
def test_negative_flex_normalization_effect(self) -> None:
|
|
"""
|
|
✅ TEST: Positive flex (normalized) produces periods.
|
|
|
|
Bug: Would use negative flex (-20%) directly in math → 100% FLEX filter
|
|
Fix: abs() ensures positive flex → reasonable filtering
|
|
"""
|
|
intervals = _create_realistic_intervals()
|
|
|
|
mock_coordinator = Mock()
|
|
mock_coordinator.config_entry = Mock()
|
|
time_service = TibberPricesTimeService(mock_coordinator)
|
|
# Mock now() to return test date
|
|
test_time = dt_util.parse_datetime("2025-11-22T12:00:00+01:00")
|
|
time_service.now = Mock(return_value=test_time)
|
|
|
|
# Test with positive flex (simulates normalized result)
|
|
config_positive = TibberPricesPeriodConfig(
|
|
flex=0.20, # Positive after normalization
|
|
min_distance_from_avg=5.0,
|
|
min_period_length=30,
|
|
reverse_sort=True,
|
|
)
|
|
|
|
result_pos = calculate_periods_with_relaxation(
|
|
intervals,
|
|
config=config_positive,
|
|
enable_relaxation=True,
|
|
min_periods=2,
|
|
max_relaxation_attempts=11,
|
|
should_show_callback=lambda _: True,
|
|
time=time_service,
|
|
config_entry=mock_coordinator.config_entry,
|
|
)
|
|
|
|
periods_pos = result_pos.get("periods", [])
|
|
|
|
# With normalized positive flex, should find periods
|
|
assert len(periods_pos) >= 2, f"Should find periods with positive flex, got {len(periods_pos)}"
|
|
|
|
def test_periods_contain_high_prices(self) -> None:
|
|
"""
|
|
✅ TEST: Peak periods contain high prices (not cheap ones).
|
|
|
|
Validates periods include expensive intervals, not cheap ones.
|
|
"""
|
|
intervals = _create_realistic_intervals()
|
|
|
|
mock_coordinator = Mock()
|
|
mock_coordinator.config_entry = Mock()
|
|
time_service = TibberPricesTimeService(mock_coordinator)
|
|
# Mock now() to return test date
|
|
test_time = dt_util.parse_datetime("2025-11-22T12:00:00+01:00")
|
|
time_service.now = Mock(return_value=test_time)
|
|
|
|
config = TibberPricesPeriodConfig(
|
|
flex=0.20,
|
|
min_distance_from_avg=5.0,
|
|
min_period_length=30,
|
|
reverse_sort=True,
|
|
)
|
|
|
|
result = calculate_periods_with_relaxation(
|
|
intervals,
|
|
config=config,
|
|
enable_relaxation=True,
|
|
min_periods=2,
|
|
max_relaxation_attempts=11,
|
|
should_show_callback=lambda _: True,
|
|
time=time_service,
|
|
config_entry=mock_coordinator.config_entry,
|
|
)
|
|
|
|
periods = result.get("periods", [])
|
|
|
|
daily_min = intervals[0]["daily_min"]
|
|
|
|
# Check period averages are NOT near daily minimum
|
|
for period in periods:
|
|
period_avg = period.get("price_mean", 0)
|
|
assert period_avg > daily_min * 1.05, (
|
|
f"Peak period has too low avg: {period_avg:.4f} vs daily_min={daily_min:.4f}"
|
|
)
|
|
|
|
def test_relaxation_works_at_reasonable_flex(self) -> None:
|
|
"""
|
|
✅ TEST: Relaxation succeeds without maxing flex at 50%.
|
|
|
|
Validates relaxation finds periods at reasonable flex levels.
|
|
"""
|
|
intervals = _create_realistic_intervals()
|
|
|
|
mock_coordinator = Mock()
|
|
mock_coordinator.config_entry = Mock()
|
|
time_service = TibberPricesTimeService(mock_coordinator)
|
|
# Mock now() to return test date
|
|
test_time = dt_util.parse_datetime("2025-11-22T12:00:00+01:00")
|
|
time_service.now = Mock(return_value=test_time)
|
|
|
|
# Lower flex to trigger relaxation
|
|
config = TibberPricesPeriodConfig(
|
|
flex=0.15, # 15% - may need relaxation
|
|
min_distance_from_avg=5.0,
|
|
min_period_length=30,
|
|
reverse_sort=True,
|
|
)
|
|
|
|
result = calculate_periods_with_relaxation(
|
|
intervals,
|
|
config=config,
|
|
enable_relaxation=True,
|
|
min_periods=2,
|
|
max_relaxation_attempts=11,
|
|
should_show_callback=lambda _: True,
|
|
time=time_service,
|
|
config_entry=mock_coordinator.config_entry,
|
|
)
|
|
|
|
periods = result.get("periods", [])
|
|
|
|
# Should find periods via relaxation
|
|
assert len(periods) >= 2, "Relaxation should find periods"
|
|
|
|
# Check if relaxation was used
|
|
relaxation_meta = result.get("metadata", {}).get("relaxation", {})
|
|
if "max_flex_used" in relaxation_meta:
|
|
max_flex_used = relaxation_meta["max_flex_used"]
|
|
# Bug would need ~50% flex
|
|
# Fix: reasonable flex (15-35%) is sufficient
|
|
assert max_flex_used <= 0.35, f"Flex should stay reasonable, got {max_flex_used * 100:.1f}%"
|
|
|
|
|
|
@pytest.mark.unit
|
|
@pytest.mark.freeze_time("2025-11-22 12:00:00+01:00")
|
|
class TestBugRegressionValidation:
|
|
"""Regression tests for the Nov 2025 sign convention bug."""
|
|
|
|
def test_metadata_shows_reasonable_flex_used(self) -> None:
|
|
"""
|
|
✅ REGRESSION: Metadata shows flex used was reasonable (not 50%).
|
|
|
|
This indirectly validates FLEX filter didn't block everything.
|
|
"""
|
|
intervals = _create_realistic_intervals()
|
|
|
|
mock_coordinator = Mock()
|
|
mock_coordinator.config_entry = Mock()
|
|
time_service = TibberPricesTimeService(mock_coordinator)
|
|
# Mock now() to return test date
|
|
test_time = dt_util.parse_datetime("2025-11-22T12:00:00+01:00")
|
|
time_service.now = Mock(return_value=test_time)
|
|
|
|
config = TibberPricesPeriodConfig(
|
|
flex=0.20,
|
|
min_distance_from_avg=5.0,
|
|
min_period_length=30,
|
|
reverse_sort=True,
|
|
)
|
|
|
|
result = calculate_periods_with_relaxation(
|
|
intervals,
|
|
config=config,
|
|
enable_relaxation=True,
|
|
min_periods=2,
|
|
max_relaxation_attempts=11,
|
|
should_show_callback=lambda _: True,
|
|
time=time_service,
|
|
config_entry=mock_coordinator.config_entry,
|
|
)
|
|
|
|
# Check metadata from result
|
|
metadata = result.get("metadata", {})
|
|
config_used = metadata.get("config", {})
|
|
|
|
if "flex" in config_used:
|
|
flex_used = config_used["flex"]
|
|
# Bug would need ~50% flex to find anything
|
|
# Fix: reasonable flex (~20-30%) is sufficient
|
|
assert 0.15 <= flex_used <= 0.35, (
|
|
f"Expected flex 15-35%, got {flex_used * 100:.1f}% (Bug would require near 50%)"
|
|
)
|
|
|
|
# Also check relaxation metadata
|
|
relaxation_meta = result.get("metadata", {}).get("relaxation", {})
|
|
if "max_flex_used" in relaxation_meta:
|
|
max_flex = relaxation_meta["max_flex_used"]
|
|
assert max_flex <= 0.35, f"Max flex should be reasonable, got {max_flex * 100:.1f}%"
|
|
|
|
def test_periods_include_expensive_intervals(self) -> None:
|
|
"""
|
|
✅ REGRESSION: Peak periods include intervals near daily max.
|
|
|
|
Bug had redundant condition: price >= ref AND price <= ref
|
|
Fix: Removed redundant condition → high prices included
|
|
"""
|
|
intervals = _create_realistic_intervals()
|
|
|
|
mock_coordinator = Mock()
|
|
mock_coordinator.config_entry = Mock()
|
|
time_service = TibberPricesTimeService(mock_coordinator)
|
|
# Mock now() to return test date
|
|
test_time = dt_util.parse_datetime("2025-11-22T12:00:00+01:00")
|
|
time_service.now = Mock(return_value=test_time)
|
|
|
|
config = TibberPricesPeriodConfig(
|
|
flex=0.20,
|
|
min_distance_from_avg=5.0,
|
|
min_period_length=30,
|
|
reverse_sort=True,
|
|
)
|
|
|
|
result = calculate_periods_with_relaxation(
|
|
intervals,
|
|
config=config,
|
|
enable_relaxation=True,
|
|
min_periods=2,
|
|
max_relaxation_attempts=11,
|
|
should_show_callback=lambda _: True,
|
|
time=time_service,
|
|
config_entry=mock_coordinator.config_entry,
|
|
)
|
|
|
|
periods = result.get("periods", [])
|
|
|
|
daily_avg = intervals[0]["daily_avg"]
|
|
daily_max = intervals[0]["daily_max"]
|
|
|
|
# At least one period should have high average
|
|
max_period_avg = max(p.get("price_mean", 0) for p in periods)
|
|
|
|
assert max_period_avg >= daily_avg * 1.05, (
|
|
f"Peak periods should have high avg: {max_period_avg:.4f} vs daily_avg={daily_avg:.4f}"
|
|
)
|
|
|
|
# Check proximity to daily max
|
|
assert max_period_avg >= daily_max * 0.85, (
|
|
f"At least one period near daily_max: {max_period_avg:.4f} vs daily_max={daily_max:.4f}"
|
|
)
|