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
351 lines
12 KiB
Python
351 lines
12 KiB
Python
"""Test period data formatting for ApexCharts visualization."""
|
|
|
|
from datetime import UTC, datetime
|
|
|
|
|
|
def test_period_array_of_arrays_with_insert_nulls() -> None:
|
|
"""
|
|
Test that period data generates 3 points per period when insert_nulls='segments'.
|
|
|
|
For ApexCharts to correctly display periods as continuous blocks:
|
|
1. Start time with price - Begin the period
|
|
2. End time with price - Hold the price level until end
|
|
3. End time with NULL - Cleanly terminate the segment (only with insert_nulls)
|
|
"""
|
|
# Simulate a period from formatters.get_period_data()
|
|
period = {
|
|
"start": datetime(2025, 12, 3, 10, 0, tzinfo=UTC),
|
|
"end": datetime(2025, 12, 3, 12, 0, tzinfo=UTC),
|
|
"price_median": 12.50, # Stored in major units (12.50 EUR)
|
|
"level": "CHEAP",
|
|
"rating_level": "LOW",
|
|
}
|
|
|
|
# Test with insert_nulls='segments' (should add NULL terminator)
|
|
chart_data = []
|
|
price_median = period["price_median"]
|
|
start_serialized = period["start"].isoformat()
|
|
end_serialized = period["end"].isoformat()
|
|
insert_nulls = "segments"
|
|
|
|
chart_data.append([start_serialized, price_median]) # 1. Start with price
|
|
chart_data.append([end_serialized, price_median]) # 2. End with price (hold level)
|
|
# 3. Add NULL terminator only if insert_nulls is enabled
|
|
if insert_nulls in ("segments", "all"):
|
|
chart_data.append([end_serialized, None]) # 3. End with NULL (terminate segment)
|
|
|
|
# Verify structure
|
|
assert len(chart_data) == 3, "Should generate 3 points with insert_nulls='segments'"
|
|
|
|
# Point 1: Start with price
|
|
assert chart_data[0][0] == "2025-12-03T10:00:00+00:00"
|
|
assert chart_data[0][1] == 12.50
|
|
|
|
# Point 2: End with price (holds level)
|
|
assert chart_data[1][0] == "2025-12-03T12:00:00+00:00"
|
|
assert chart_data[1][1] == 12.50
|
|
|
|
# Point 3: End with NULL (terminates segment)
|
|
assert chart_data[2][0] == "2025-12-03T12:00:00+00:00"
|
|
assert chart_data[2][1] is None
|
|
|
|
|
|
def test_period_array_of_arrays_without_insert_nulls() -> None:
|
|
"""
|
|
Test that period data generates 2 points per period when insert_nulls='none'.
|
|
|
|
Without NULL insertion, we only get:
|
|
1. Start time with price
|
|
2. End time with price
|
|
"""
|
|
period = {
|
|
"start": datetime(2025, 12, 3, 10, 0, tzinfo=UTC),
|
|
"end": datetime(2025, 12, 3, 12, 0, tzinfo=UTC),
|
|
"price_median": 12.50,
|
|
}
|
|
|
|
# Test with insert_nulls='none' (should NOT add NULL terminator)
|
|
chart_data = []
|
|
price_median = period["price_median"]
|
|
start_serialized = period["start"].isoformat()
|
|
end_serialized = period["end"].isoformat()
|
|
insert_nulls = "none"
|
|
|
|
chart_data.append([start_serialized, price_median])
|
|
chart_data.append([end_serialized, price_median])
|
|
if insert_nulls in ("segments", "all"):
|
|
chart_data.append([end_serialized, None])
|
|
|
|
# Verify structure: Only 2 points without NULL terminator
|
|
assert len(chart_data) == 2, "Should generate 2 points with insert_nulls='none'"
|
|
assert chart_data[0][1] == 12.50
|
|
assert chart_data[1][1] == 12.50
|
|
|
|
|
|
def test_multiple_periods_separated_by_nulls() -> None:
|
|
"""
|
|
Test that multiple periods are properly separated by NULL points with insert_nulls enabled.
|
|
|
|
This ensures gaps between periods are visualized correctly in ApexCharts.
|
|
"""
|
|
periods = [
|
|
{
|
|
"start": datetime(2025, 12, 3, 10, 0, tzinfo=UTC),
|
|
"end": datetime(2025, 12, 3, 12, 0, tzinfo=UTC),
|
|
"price_median": 12.50,
|
|
},
|
|
{
|
|
"start": datetime(2025, 12, 3, 15, 0, tzinfo=UTC),
|
|
"end": datetime(2025, 12, 3, 17, 0, tzinfo=UTC),
|
|
"price_median": 18.50,
|
|
},
|
|
]
|
|
|
|
chart_data = []
|
|
insert_nulls = "segments"
|
|
for period in periods:
|
|
price_median = period["price_median"]
|
|
start_serialized = period["start"].isoformat()
|
|
end_serialized = period["end"].isoformat()
|
|
|
|
chart_data.append([start_serialized, price_median])
|
|
chart_data.append([end_serialized, price_median])
|
|
if insert_nulls in ("segments", "all"):
|
|
chart_data.append([end_serialized, None])
|
|
|
|
# Verify structure: 2 periods x 3 points = 6 total points (with insert_nulls)
|
|
assert len(chart_data) == 6, "Should generate 6 points for 2 periods with insert_nulls"
|
|
|
|
# Period 1 ends with NULL
|
|
assert chart_data[2][1] is None
|
|
|
|
# Period 2 starts
|
|
assert chart_data[3][0] == "2025-12-03T15:00:00+00:00"
|
|
assert chart_data[3][1] == 18.50
|
|
|
|
# Period 2 ends with NULL
|
|
assert chart_data[5][1] is None
|
|
|
|
|
|
def test_multiple_periods_without_nulls() -> None:
|
|
"""
|
|
Test that multiple periods without insert_nulls generate continuous data.
|
|
|
|
Without NULL separators, periods connect directly (may be desired for some chart types).
|
|
"""
|
|
periods = [
|
|
{
|
|
"start": datetime(2025, 12, 3, 10, 0, tzinfo=UTC),
|
|
"end": datetime(2025, 12, 3, 12, 0, tzinfo=UTC),
|
|
"price_median": 12.50,
|
|
},
|
|
{
|
|
"start": datetime(2025, 12, 3, 15, 0, tzinfo=UTC),
|
|
"end": datetime(2025, 12, 3, 17, 0, tzinfo=UTC),
|
|
"price_median": 18.50,
|
|
},
|
|
]
|
|
|
|
chart_data = []
|
|
insert_nulls = "none"
|
|
for period in periods:
|
|
price_median = period["price_median"]
|
|
start_serialized = period["start"].isoformat()
|
|
end_serialized = period["end"].isoformat()
|
|
|
|
chart_data.append([start_serialized, price_median])
|
|
chart_data.append([end_serialized, price_median])
|
|
if insert_nulls in ("segments", "all"):
|
|
chart_data.append([end_serialized, None])
|
|
|
|
# Verify structure: 2 periods x 2 points = 4 total points (without insert_nulls)
|
|
assert len(chart_data) == 4, "Should generate 4 points for 2 periods without insert_nulls"
|
|
|
|
# No NULL separators
|
|
assert all(point[1] is not None for point in chart_data)
|
|
|
|
|
|
def test_period_currency_conversion() -> None:
|
|
"""
|
|
Test that period prices are correctly converted between major/subunit currency.
|
|
|
|
Period prices are stored in major units (€/kr/$) in coordinator data.
|
|
"""
|
|
period = {
|
|
"start": datetime(2025, 12, 3, 10, 0, tzinfo=UTC),
|
|
"end": datetime(2025, 12, 3, 12, 0, tzinfo=UTC),
|
|
"price_median": 12.50, # 12.50 €/kr (base currency)
|
|
}
|
|
|
|
# Test 1: Keep base currency (default for services)
|
|
price_major = period["price_median"]
|
|
assert price_major == 12.50, "Should keep major units (EUR)"
|
|
|
|
# Test 2: Convert to subunit currency (if subunit_currency=True)
|
|
price_minor = period["price_median"] * 100
|
|
assert price_minor == 1250, "Should convert to minor units (ct/øre)"
|
|
|
|
|
|
def test_period_with_missing_end_time() -> None:
|
|
"""
|
|
Test handling of periods without end time (incomplete period).
|
|
|
|
If a period has no end time, we should only add the start point.
|
|
"""
|
|
period = {
|
|
"start": datetime(2025, 12, 3, 10, 0, tzinfo=UTC),
|
|
"end": None, # No end time
|
|
"price_median": 12.50,
|
|
}
|
|
|
|
chart_data = []
|
|
price_median = period["price_median"]
|
|
start_serialized = period["start"].isoformat()
|
|
end = period.get("end")
|
|
end_serialized = end.isoformat() if end else None
|
|
insert_nulls = "segments"
|
|
|
|
# Add start point
|
|
chart_data.append([start_serialized, price_median])
|
|
|
|
# Only add end points if end_serialized exists
|
|
if end_serialized:
|
|
chart_data.append([end_serialized, price_median])
|
|
if insert_nulls in ("segments", "all"):
|
|
chart_data.append([end_serialized, None])
|
|
|
|
# Verify: Only 1 point (start) for incomplete period
|
|
assert len(chart_data) == 1, "Should only have start point for incomplete period"
|
|
assert chart_data[0][1] == 12.50
|
|
|
|
|
|
def test_apexcharts_mapping_preserves_structure() -> None:
|
|
"""
|
|
Test that ApexCharts .map() transformation preserves the 3-point structure.
|
|
|
|
The ApexCharts data_generator uses: .map(point => [point[0], 1])
|
|
This should preserve all 3 points but replace price with 1 (for overlay).
|
|
"""
|
|
# Simulate period data (3 points per period with insert_nulls='segments')
|
|
period_data = [
|
|
["2025-12-03T10:00:00+00:00", 1250], # Start with price
|
|
["2025-12-03T12:00:00+00:00", 1250], # End with price
|
|
["2025-12-03T12:00:00+00:00", None], # End with NULL
|
|
]
|
|
|
|
# Simulate ApexCharts mapping: [timestamp, 1] for overlay
|
|
mapped_data = [[point[0], 1 if point[1] is not None else None] for point in period_data]
|
|
|
|
# Verify structure is preserved
|
|
assert len(mapped_data) == 3, "Should preserve all 3 points"
|
|
assert mapped_data[0] == ["2025-12-03T10:00:00+00:00", 1] # Start
|
|
assert mapped_data[1] == ["2025-12-03T12:00:00+00:00", 1] # End (hold)
|
|
assert mapped_data[2] == ["2025-12-03T12:00:00+00:00", None] # End (terminate)
|
|
|
|
|
|
def test_insert_nulls_all_mode() -> None:
|
|
"""
|
|
Test that insert_nulls='all' also adds NULL terminators.
|
|
|
|
The 'all' mode should behave the same as 'segments' for period data.
|
|
"""
|
|
period = {
|
|
"start": datetime(2025, 12, 3, 10, 0, tzinfo=UTC),
|
|
"end": datetime(2025, 12, 3, 12, 0, tzinfo=UTC),
|
|
"price_median": 1250,
|
|
}
|
|
|
|
chart_data = []
|
|
price_median = period["price_median"]
|
|
start_serialized = period["start"].isoformat()
|
|
end_serialized = period["end"].isoformat()
|
|
insert_nulls = "all"
|
|
|
|
chart_data.append([start_serialized, price_median])
|
|
chart_data.append([end_serialized, price_median])
|
|
if insert_nulls in ("segments", "all"):
|
|
chart_data.append([end_serialized, None])
|
|
|
|
# Verify: 3 points with insert_nulls='all'
|
|
assert len(chart_data) == 3, "Should generate 3 points with insert_nulls='all'"
|
|
assert chart_data[2][1] is None
|
|
|
|
|
|
def test_insert_nulls_and_add_trailing_null_both_enabled() -> None:
|
|
"""
|
|
Test that both insert_nulls and add_trailing_null work together correctly.
|
|
|
|
When both are enabled, you should get:
|
|
- NULL terminator after each period (from insert_nulls)
|
|
- Additional NULL at the very end (from add_trailing_null)
|
|
|
|
This results in TWO NULL points at the end: one for the last period, one trailing.
|
|
"""
|
|
periods = [
|
|
{
|
|
"start": datetime(2025, 12, 3, 10, 0, tzinfo=UTC),
|
|
"end": datetime(2025, 12, 3, 12, 0, tzinfo=UTC),
|
|
"price_median": 1250,
|
|
},
|
|
]
|
|
|
|
chart_data = []
|
|
insert_nulls = "segments"
|
|
add_trailing_null = True
|
|
|
|
for period in periods:
|
|
price_median = period["price_median"]
|
|
start_serialized = period["start"].isoformat()
|
|
end_serialized = period["end"].isoformat()
|
|
|
|
chart_data.append([start_serialized, price_median])
|
|
chart_data.append([end_serialized, price_median])
|
|
if insert_nulls in ("segments", "all"):
|
|
chart_data.append([end_serialized, None])
|
|
|
|
# Add trailing null
|
|
if add_trailing_null:
|
|
chart_data.append([None, None])
|
|
|
|
# Verify: 3 points (period) + 1 trailing = 4 total
|
|
assert len(chart_data) == 4, "Should have 4 points with both insert_nulls and add_trailing_null"
|
|
|
|
# Last period's NULL terminator
|
|
assert chart_data[2][0] == "2025-12-03T12:00:00+00:00"
|
|
assert chart_data[2][1] is None
|
|
|
|
# Trailing NULL (completely null)
|
|
assert chart_data[3][0] is None
|
|
assert chart_data[3][1] is None
|
|
|
|
|
|
def test_neither_insert_nulls_nor_add_trailing_null() -> None:
|
|
"""
|
|
Test that when both insert_nulls='none' and add_trailing_null=False, no NULLs are added.
|
|
|
|
This gives clean period data without any NULL separators.
|
|
"""
|
|
period = {
|
|
"start": datetime(2025, 12, 3, 10, 0, tzinfo=UTC),
|
|
"end": datetime(2025, 12, 3, 12, 0, tzinfo=UTC),
|
|
"price_median": 1250,
|
|
}
|
|
|
|
chart_data = []
|
|
price_median = period["price_median"]
|
|
start_serialized = period["start"].isoformat()
|
|
end_serialized = period["end"].isoformat()
|
|
insert_nulls = "none"
|
|
add_trailing_null = False
|
|
|
|
chart_data.append([start_serialized, price_median])
|
|
chart_data.append([end_serialized, price_median])
|
|
if insert_nulls in ("segments", "all"):
|
|
chart_data.append([end_serialized, None])
|
|
|
|
if add_trailing_null:
|
|
chart_data.append([None, None])
|
|
|
|
# Verify: Only 2 points (start, end) without any NULLs
|
|
assert len(chart_data) == 2, "Should have 2 points without NULL insertion"
|
|
assert all(point[1] is not None for point in chart_data), "No NULL values should be present"
|