hass.tibber_prices/custom_components/tibber_prices/sensor/value_getters.py
Julian Pawlowski 60e05e0815 refactor(currency)!: rename major/minor to base/subunit currency terminology
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
2025-12-11 08:26:30 +00:00

283 lines
16 KiB
Python

"""Value getter mapping for Tibber Prices sensors."""
from __future__ import annotations
from typing import TYPE_CHECKING
from custom_components.tibber_prices.utils.average import (
calculate_current_leading_avg,
calculate_current_leading_max,
calculate_current_leading_min,
calculate_current_trailing_avg,
calculate_current_trailing_max,
calculate_current_trailing_min,
calculate_median,
)
if TYPE_CHECKING:
from collections.abc import Callable
from datetime import datetime
from custom_components.tibber_prices.sensor.calculators.daily_stat import TibberPricesDailyStatCalculator
from custom_components.tibber_prices.sensor.calculators.interval import TibberPricesIntervalCalculator
from custom_components.tibber_prices.sensor.calculators.lifecycle import TibberPricesLifecycleCalculator
from custom_components.tibber_prices.sensor.calculators.metadata import TibberPricesMetadataCalculator
from custom_components.tibber_prices.sensor.calculators.rolling_hour import TibberPricesRollingHourCalculator
from custom_components.tibber_prices.sensor.calculators.timing import TibberPricesTimingCalculator
from custom_components.tibber_prices.sensor.calculators.trend import TibberPricesTrendCalculator
from custom_components.tibber_prices.sensor.calculators.volatility import TibberPricesVolatilityCalculator
from custom_components.tibber_prices.sensor.calculators.window_24h import TibberPricesWindow24hCalculator
def get_value_getter_mapping( # noqa: PLR0913 - needs all calculators as parameters
interval_calculator: TibberPricesIntervalCalculator,
rolling_hour_calculator: TibberPricesRollingHourCalculator,
daily_stat_calculator: TibberPricesDailyStatCalculator,
window_24h_calculator: TibberPricesWindow24hCalculator,
trend_calculator: TibberPricesTrendCalculator,
timing_calculator: TibberPricesTimingCalculator,
volatility_calculator: TibberPricesVolatilityCalculator,
metadata_calculator: TibberPricesMetadataCalculator,
lifecycle_calculator: TibberPricesLifecycleCalculator,
get_next_avg_n_hours_value: Callable[[int], float | None],
get_data_timestamp: Callable[[], datetime | None],
get_chart_data_export_value: Callable[[], str | None],
get_chart_metadata_value: Callable[[], str | None],
) -> dict[str, Callable]:
"""
Build mapping from entity key to value getter callable.
This function centralizes the handler mapping logic, making it easier to maintain
and understand the relationship between sensor types and their calculation methods.
Args:
interval_calculator: Calculator for current/next/previous interval values
rolling_hour_calculator: Calculator for 5-interval rolling windows
daily_stat_calculator: Calculator for daily min/max/avg statistics
window_24h_calculator: Calculator for trailing/leading 24h windows
trend_calculator: Calculator for price trend analysis
timing_calculator: Calculator for best/peak price period timing
volatility_calculator: Calculator for price volatility analysis
metadata_calculator: Calculator for home/metering metadata
lifecycle_calculator: Calculator for data lifecycle tracking
get_next_avg_n_hours_value: Method for next N-hour average forecasts
get_data_timestamp: Method for data timestamp sensor
get_chart_data_export_value: Method for chart data export sensor
get_chart_metadata_value: Method for chart metadata sensor
Returns:
Dictionary mapping entity keys to their value getter callables.
"""
return {
# ================================================================
# INTERVAL-BASED SENSORS - via IntervalCalculator
# ================================================================
# Price level sensors
"current_interval_price_level": interval_calculator.get_price_level_value,
"next_interval_price_level": lambda: interval_calculator.get_interval_value(
interval_offset=1, value_type="level"
),
"previous_interval_price_level": lambda: interval_calculator.get_interval_value(
interval_offset=-1, value_type="level"
),
# Price sensors (in cents)
"current_interval_price": lambda: interval_calculator.get_interval_value(
interval_offset=0, value_type="price", in_euro=False
),
"current_interval_price_base": lambda: interval_calculator.get_interval_value(
interval_offset=0, value_type="price", in_euro=True
),
"next_interval_price": lambda: interval_calculator.get_interval_value(
interval_offset=1, value_type="price", in_euro=False
),
"previous_interval_price": lambda: interval_calculator.get_interval_value(
interval_offset=-1, value_type="price", in_euro=False
),
# Rating sensors
"current_interval_price_rating": lambda: interval_calculator.get_rating_value(rating_type="current"),
"next_interval_price_rating": lambda: interval_calculator.get_interval_value(
interval_offset=1, value_type="rating"
),
"previous_interval_price_rating": lambda: interval_calculator.get_interval_value(
interval_offset=-1, value_type="rating"
),
# ================================================================
# ROLLING HOUR SENSORS (5-interval windows) - via RollingHourCalculator
# ================================================================
"current_hour_price_level": lambda: rolling_hour_calculator.get_rolling_hour_value(
hour_offset=0, value_type="level"
),
"next_hour_price_level": lambda: rolling_hour_calculator.get_rolling_hour_value(
hour_offset=1, value_type="level"
),
# Rolling hour average (5 intervals: 2 before + current + 2 after)
"current_hour_average_price": lambda: rolling_hour_calculator.get_rolling_hour_value(
hour_offset=0, value_type="price"
),
"next_hour_average_price": lambda: rolling_hour_calculator.get_rolling_hour_value(
hour_offset=1, value_type="price"
),
"current_hour_price_rating": lambda: rolling_hour_calculator.get_rolling_hour_value(
hour_offset=0, value_type="rating"
),
"next_hour_price_rating": lambda: rolling_hour_calculator.get_rolling_hour_value(
hour_offset=1, value_type="rating"
),
# ================================================================
# DAILY STATISTICS SENSORS - via DailyStatCalculator
# ================================================================
"lowest_price_today": lambda: daily_stat_calculator.get_daily_stat_value(day="today", stat_func=min),
"highest_price_today": lambda: daily_stat_calculator.get_daily_stat_value(day="today", stat_func=max),
"average_price_today": lambda: daily_stat_calculator.get_daily_stat_value(
day="today",
stat_func=lambda prices: (sum(prices) / len(prices), calculate_median(prices)),
),
# Tomorrow statistics sensors
"lowest_price_tomorrow": lambda: daily_stat_calculator.get_daily_stat_value(day="tomorrow", stat_func=min),
"highest_price_tomorrow": lambda: daily_stat_calculator.get_daily_stat_value(day="tomorrow", stat_func=max),
"average_price_tomorrow": lambda: daily_stat_calculator.get_daily_stat_value(
day="tomorrow",
stat_func=lambda prices: (sum(prices) / len(prices), calculate_median(prices)),
),
# Daily aggregated level sensors
"yesterday_price_level": lambda: daily_stat_calculator.get_daily_aggregated_value(
day="yesterday", value_type="level"
),
"today_price_level": lambda: daily_stat_calculator.get_daily_aggregated_value(day="today", value_type="level"),
"tomorrow_price_level": lambda: daily_stat_calculator.get_daily_aggregated_value(
day="tomorrow", value_type="level"
),
# Daily aggregated rating sensors
"yesterday_price_rating": lambda: daily_stat_calculator.get_daily_aggregated_value(
day="yesterday", value_type="rating"
),
"today_price_rating": lambda: daily_stat_calculator.get_daily_aggregated_value(
day="today", value_type="rating"
),
"tomorrow_price_rating": lambda: daily_stat_calculator.get_daily_aggregated_value(
day="tomorrow", value_type="rating"
),
# ================================================================
# 24H WINDOW SENSORS (trailing/leading from current) - via TibberPricesWindow24hCalculator
# ================================================================
# Trailing and leading average sensors
"trailing_price_average": lambda: window_24h_calculator.get_24h_window_value(
stat_func=calculate_current_trailing_avg,
),
"leading_price_average": lambda: window_24h_calculator.get_24h_window_value(
stat_func=calculate_current_leading_avg,
),
# Trailing and leading min/max sensors
"trailing_price_min": lambda: window_24h_calculator.get_24h_window_value(
stat_func=calculate_current_trailing_min,
),
"trailing_price_max": lambda: window_24h_calculator.get_24h_window_value(
stat_func=calculate_current_trailing_max,
),
"leading_price_min": lambda: window_24h_calculator.get_24h_window_value(
stat_func=calculate_current_leading_min,
),
"leading_price_max": lambda: window_24h_calculator.get_24h_window_value(
stat_func=calculate_current_leading_max,
),
# ================================================================
# FUTURE FORECAST SENSORS
# ================================================================
# Future average sensors (next N hours from next interval)
"next_avg_1h": lambda: get_next_avg_n_hours_value(1),
"next_avg_2h": lambda: get_next_avg_n_hours_value(2),
"next_avg_3h": lambda: get_next_avg_n_hours_value(3),
"next_avg_4h": lambda: get_next_avg_n_hours_value(4),
"next_avg_5h": lambda: get_next_avg_n_hours_value(5),
"next_avg_6h": lambda: get_next_avg_n_hours_value(6),
"next_avg_8h": lambda: get_next_avg_n_hours_value(8),
"next_avg_12h": lambda: get_next_avg_n_hours_value(12),
# Current and next trend change sensors
"current_price_trend": trend_calculator.get_current_trend_value,
"next_price_trend_change": trend_calculator.get_next_trend_change_value,
# Price trend sensors
"price_trend_1h": lambda: trend_calculator.get_price_trend_value(hours=1),
"price_trend_2h": lambda: trend_calculator.get_price_trend_value(hours=2),
"price_trend_3h": lambda: trend_calculator.get_price_trend_value(hours=3),
"price_trend_4h": lambda: trend_calculator.get_price_trend_value(hours=4),
"price_trend_5h": lambda: trend_calculator.get_price_trend_value(hours=5),
"price_trend_6h": lambda: trend_calculator.get_price_trend_value(hours=6),
"price_trend_8h": lambda: trend_calculator.get_price_trend_value(hours=8),
"price_trend_12h": lambda: trend_calculator.get_price_trend_value(hours=12),
# Diagnostic sensors
"data_timestamp": get_data_timestamp,
# Data lifecycle status sensor
"data_lifecycle_status": lambda: lifecycle_calculator.get_lifecycle_state(),
# Home metadata sensors (via MetadataCalculator)
"home_type": lambda: metadata_calculator.get_home_metadata_value("type"),
"home_size": lambda: metadata_calculator.get_home_metadata_value("size"),
"main_fuse_size": lambda: metadata_calculator.get_home_metadata_value("mainFuseSize"),
"number_of_residents": lambda: metadata_calculator.get_home_metadata_value("numberOfResidents"),
"primary_heating_source": lambda: metadata_calculator.get_home_metadata_value("primaryHeatingSource"),
# Metering point sensors (via MetadataCalculator)
"grid_company": lambda: metadata_calculator.get_metering_point_value("gridCompany"),
"grid_area_code": lambda: metadata_calculator.get_metering_point_value("gridAreaCode"),
"price_area_code": lambda: metadata_calculator.get_metering_point_value("priceAreaCode"),
"consumption_ean": lambda: metadata_calculator.get_metering_point_value("consumptionEan"),
"production_ean": lambda: metadata_calculator.get_metering_point_value("productionEan"),
"energy_tax_type": lambda: metadata_calculator.get_metering_point_value("energyTaxType"),
"vat_type": lambda: metadata_calculator.get_metering_point_value("vatType"),
"estimated_annual_consumption": lambda: metadata_calculator.get_metering_point_value(
"estimatedAnnualConsumption"
),
# Subscription sensors (via MetadataCalculator)
"subscription_status": lambda: metadata_calculator.get_subscription_value("status"),
# Volatility sensors (via VolatilityCalculator)
"today_volatility": lambda: volatility_calculator.get_volatility_value(volatility_type="today"),
"tomorrow_volatility": lambda: volatility_calculator.get_volatility_value(volatility_type="tomorrow"),
"next_24h_volatility": lambda: volatility_calculator.get_volatility_value(volatility_type="next_24h"),
"today_tomorrow_volatility": lambda: volatility_calculator.get_volatility_value(
volatility_type="today_tomorrow"
),
# ================================================================
# BEST/PEAK PRICE TIMING SENSORS - via TimingCalculator
# ================================================================
# Best Price timing sensors
"best_price_end_time": lambda: timing_calculator.get_period_timing_value(
period_type="best_price", value_type="end_time"
),
"best_price_period_duration": lambda: timing_calculator.get_period_timing_value(
period_type="best_price", value_type="period_duration"
),
"best_price_remaining_minutes": lambda: timing_calculator.get_period_timing_value(
period_type="best_price", value_type="remaining_minutes"
),
"best_price_progress": lambda: timing_calculator.get_period_timing_value(
period_type="best_price", value_type="progress"
),
"best_price_next_start_time": lambda: timing_calculator.get_period_timing_value(
period_type="best_price", value_type="next_start_time"
),
"best_price_next_in_minutes": lambda: timing_calculator.get_period_timing_value(
period_type="best_price", value_type="next_in_minutes"
),
# Peak Price timing sensors
"peak_price_end_time": lambda: timing_calculator.get_period_timing_value(
period_type="peak_price", value_type="end_time"
),
"peak_price_period_duration": lambda: timing_calculator.get_period_timing_value(
period_type="peak_price", value_type="period_duration"
),
"peak_price_remaining_minutes": lambda: timing_calculator.get_period_timing_value(
period_type="peak_price", value_type="remaining_minutes"
),
"peak_price_progress": lambda: timing_calculator.get_period_timing_value(
period_type="peak_price", value_type="progress"
),
"peak_price_next_start_time": lambda: timing_calculator.get_period_timing_value(
period_type="peak_price", value_type="next_start_time"
),
"peak_price_next_in_minutes": lambda: timing_calculator.get_period_timing_value(
period_type="peak_price", value_type="next_in_minutes"
),
# Chart data export sensor
"chart_data_export": get_chart_data_export_value,
# Chart metadata sensor
"chart_metadata": get_chart_metadata_value,
}