mirror of
https://github.com/jpawlowski/hass.tibber_prices.git
synced 2026-03-29 21:03:40 +00:00
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
This commit is contained in:
parent
ddc092a3a4
commit
60e05e0815
55 changed files with 854 additions and 376 deletions
|
|
@ -422,7 +422,7 @@ After successful refactoring:
|
|||
- **Architecture Benefits**: 42% line reduction in core.py (2,170 → 1,268 lines), clear separation of concerns, improved testability, reusable components
|
||||
- **See "Common Tasks" section** for detailed patterns and examples
|
||||
- **Quarter-hour precision**: Entities update on 00/15/30/45-minute boundaries via `schedule_quarter_hour_refresh()` in `coordinator/listeners.py`, not just on data fetch intervals. Uses `async_track_utc_time_change(minute=[0, 15, 30, 45], second=0)` for absolute-time scheduling. Smart boundary tolerance (±2 seconds) in `sensor/helpers.py` → `round_to_nearest_quarter_hour()` handles HA scheduling jitter: if HA triggers at 14:59:58 → rounds to 15:00:00 (next interval), if HA restarts at 14:59:30 → stays at 14:45:00 (current interval). This ensures current price sensors update without waiting for the next API poll, while preventing premature data display during normal operation.
|
||||
- **Currency handling**: Multi-currency support with major/minor units (e.g., EUR/ct, NOK/øre) via `get_currency_info()` and `format_price_unit_*()` in `const.py`.
|
||||
- **Currency handling**: Multi-currency support with base/sub units (e.g., EUR/ct, NOK/øre) via `get_currency_info()` and `format_price_unit_*()` in `const.py`.
|
||||
- **Intelligent caching strategy**: Minimizes API calls while ensuring data freshness:
|
||||
- User data cached for 24h (rarely changes)
|
||||
- Price data validated against calendar day - cleared on midnight turnover to force fresh fetch
|
||||
|
|
@ -2741,12 +2741,12 @@ The refactoring consolidated duplicate logic into unified methods in `sensor/cor
|
|||
|
||||
- Replaces: `_get_statistics_value()` (calendar day portion)
|
||||
- Handles: Min/max/avg for calendar days (today/tomorrow)
|
||||
- Returns: Price in minor currency units (cents/øre)
|
||||
- Returns: Price in subunit currency units (cents/øre)
|
||||
|
||||
- **`_get_24h_window_value(stat_func)`**
|
||||
- Replaces: `_get_average_value()`, `_get_minmax_value()`
|
||||
- Handles: Trailing/leading 24h window statistics
|
||||
- Returns: Price in minor currency units (cents/øre)
|
||||
- Returns: Price in subunit currency units (cents/øre)
|
||||
|
||||
Legacy wrapper methods still exist for backward compatibility but will be removed in a future cleanup phase.
|
||||
|
||||
|
|
|
|||
19
README.md
19
README.md
|
|
@ -35,7 +35,7 @@ A custom Home Assistant integration that provides advanced electricity price inf
|
|||
## ✨ Features
|
||||
|
||||
- **Quarter-Hourly Price Data**: Access detailed 15-minute interval pricing (384 data points across 4 days: day before yesterday/yesterday/today/tomorrow)
|
||||
- **Current and Next Interval Prices**: Get real-time price data in both major currency (€, kr) and minor units (ct, øre)
|
||||
- **Flexible Currency Display**: Choose between base currency (€, kr) or subunit (ct, øre) display - configurable per your preference with smart defaults
|
||||
- **Multi-Currency Support**: Automatic detection and formatting for EUR, NOK, SEK, DKK, USD, and GBP
|
||||
- **Price Level Indicators**: Know when you're in a VERY_CHEAP, CHEAP, NORMAL, EXPENSIVE, or VERY_EXPENSIVE period
|
||||
- **Statistical Sensors**: Track lowest, highest, and average prices for the day
|
||||
|
|
@ -165,13 +165,15 @@ The following sensors are available but disabled by default. Enable them in `Set
|
|||
- **Previous Interval Price** & **Previous Interval Price Level**: Historical data for the last 15-minute interval
|
||||
- **Previous Interval Price Rating**: Rating for the previous interval
|
||||
- **Trailing 24h Average Price**: Average of the past 24 hours from now
|
||||
- **Trailing 24h Minimum/Maximum Price**: Min/max in the past 24 hours
|
||||
- **Trailing 24h Minimum/Maximum Price**: Min/max in the past 24 hours
|
||||
|
||||
> **Note**: All monetary sensors use minor currency units (ct/kWh, øre/kWh, ¢/kWh, p/kWh) automatically based on your Tibber account's currency. Supported: EUR, NOK, SEK, DKK, USD, GBP.
|
||||
> **Note**: Currency display is configurable during setup. Choose between:
|
||||
> - **Base currency** (€/kWh, kr/kWh) - decimal values, differences visible from 3rd-4th decimal
|
||||
> - **Subunit** (ct/kWh, øre/kWh) - larger values, differences visible from 1st decimal
|
||||
>
|
||||
> Smart defaults: EUR → subunit (German/Dutch preference), NOK/SEK/DKK → base (Scandinavian preference). Supported currencies: EUR, NOK, SEK, DKK, USD, GBP.
|
||||
|
||||
## Automation Examples
|
||||
|
||||
> **Note:** See the [full automation examples guide](https://jpawlowski.github.io/hass.tibber_prices/user/automation-examples) for more advanced recipes.
|
||||
## Automation Examples> **Note:** See the [full automation examples guide](https://jpawlowski.github.io/hass.tibber_prices/user/automation-examples) for more advanced recipes.
|
||||
|
||||
### Run Appliances During Cheap Hours
|
||||
|
||||
|
|
@ -282,8 +284,9 @@ automation:
|
|||
### Currency or units showing incorrectly
|
||||
|
||||
- Currency is automatically detected from your Tibber account
|
||||
- The integration supports EUR, NOK, SEK, DKK, USD, and GBP with appropriate minor units
|
||||
- Enable/disable major vs. minor unit sensors in `Settings > Devices & Services > Tibber Price Information & Ratings > Entities`
|
||||
- Display mode (base currency vs. subunit) can be configured in integration options: `Settings > Devices & Services > Tibber Price Information & Ratings > Configure`
|
||||
- Supported currencies: EUR, NOK, SEK, DKK, USD, and GBP
|
||||
- Smart defaults apply: EUR users get subunit (ct), Scandinavian users get base currency (kr)
|
||||
|
||||
## Advanced Features
|
||||
|
||||
|
|
|
|||
|
|
@ -20,8 +20,10 @@ from homeassistant.loader import async_get_loaded_integration
|
|||
|
||||
from .api import TibberPricesApiClient
|
||||
from .const import (
|
||||
CONF_CURRENCY_DISPLAY_MODE,
|
||||
DATA_CHART_CONFIG,
|
||||
DATA_CHART_METADATA_CONFIG,
|
||||
DISPLAY_MODE_SUBUNIT,
|
||||
DOMAIN,
|
||||
LOGGER,
|
||||
async_load_standard_translations,
|
||||
|
|
@ -57,7 +59,7 @@ CONFIG_SCHEMA = vol.Schema(
|
|||
vol.Optional("day"): vol.All(vol.Any(str, list), vol.Coerce(list)),
|
||||
vol.Optional("resolution"): str,
|
||||
vol.Optional("output_format"): str,
|
||||
vol.Optional("minor_currency"): bool,
|
||||
vol.Optional("subunit_currency"): bool,
|
||||
vol.Optional("round_decimals"): vol.All(int, vol.Range(min=0, max=10)),
|
||||
vol.Optional("include_level"): bool,
|
||||
vol.Optional("include_rating_level"): bool,
|
||||
|
|
@ -114,6 +116,34 @@ async def async_setup(hass: HomeAssistant, config: dict[str, Any]) -> bool:
|
|||
return True
|
||||
|
||||
|
||||
async def _migrate_config_options(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||
"""
|
||||
Migrate config options for backward compatibility.
|
||||
|
||||
This ensures existing configs get sensible defaults when new options are added.
|
||||
Runs automatically on integration startup.
|
||||
"""
|
||||
migration_performed = False
|
||||
migrated = dict(entry.options)
|
||||
|
||||
# Migration: Set currency_display_mode to minor for existing configs
|
||||
# New configs get currency-appropriate defaults from schema.
|
||||
# This preserves legacy behavior where all prices were in subunit currency.
|
||||
if CONF_CURRENCY_DISPLAY_MODE not in migrated:
|
||||
migrated[CONF_CURRENCY_DISPLAY_MODE] = DISPLAY_MODE_SUBUNIT
|
||||
migration_performed = True
|
||||
LOGGER.info(
|
||||
"[%s] Migrated config: Set currency_display_mode=%s (legacy default for existing configs)",
|
||||
entry.title,
|
||||
DISPLAY_MODE_SUBUNIT,
|
||||
)
|
||||
|
||||
# Save migrated options if any changes were made
|
||||
if migration_performed:
|
||||
hass.config_entries.async_update_entry(entry, options=migrated)
|
||||
LOGGER.debug("[%s] Config migration completed", entry.title)
|
||||
|
||||
|
||||
def _get_access_token(hass: HomeAssistant, entry: ConfigEntry) -> str:
|
||||
"""
|
||||
Get access token from entry or parent entry.
|
||||
|
|
@ -158,6 +188,10 @@ async def async_setup_entry(
|
|||
) -> bool:
|
||||
"""Set up this integration using UI."""
|
||||
LOGGER.debug(f"[tibber_prices] async_setup_entry called for entry_id={entry.entry_id}")
|
||||
|
||||
# Migrate config options if needed (e.g., set default currency display mode for existing configs)
|
||||
await _migrate_config_options(hass, entry)
|
||||
|
||||
# Preload translations to populate the cache
|
||||
await async_load_translations(hass, "en")
|
||||
await async_load_standard_translations(hass, "en")
|
||||
|
|
|
|||
|
|
@ -90,15 +90,15 @@ class PeriodSummary(TypedDict, total=False):
|
|||
rating_difference_pct: float # Difference from daily average (%)
|
||||
|
||||
# Price statistics (priority 3)
|
||||
price_mean: float # Arithmetic mean price in period (minor currency)
|
||||
price_median: float # Median price in period (minor currency)
|
||||
price_min: float # Minimum price in period (minor currency)
|
||||
price_max: float # Maximum price in period (minor currency)
|
||||
price_mean: float # Arithmetic mean price in period
|
||||
price_median: float # Median price in period
|
||||
price_min: float # Minimum price in period
|
||||
price_max: float # Maximum price in period
|
||||
price_spread: float # Price spread (max - min)
|
||||
volatility: float # Price volatility within period
|
||||
|
||||
# Price comparison (priority 4)
|
||||
period_price_diff_from_daily_min: float # Difference from daily min (minor currency)
|
||||
period_price_diff_from_daily_min: float # Difference from daily min
|
||||
period_price_diff_from_daily_min_pct: float # Difference from daily min (%)
|
||||
|
||||
# Detail information (priority 5)
|
||||
|
|
@ -141,15 +141,15 @@ class PeriodAttributes(BaseAttributes, total=False):
|
|||
rating_difference_pct: float # Difference from daily average (%)
|
||||
|
||||
# Price statistics (priority 3)
|
||||
price_mean: float # Arithmetic mean price in current/next period (minor currency)
|
||||
price_median: float # Median price in current/next period (minor currency)
|
||||
price_min: float # Minimum price in current/next period (minor currency)
|
||||
price_max: float # Maximum price in current/next period (minor currency)
|
||||
price_mean: float # Arithmetic mean price in current/next period
|
||||
price_median: float # Median price in current/next period
|
||||
price_min: float # Minimum price in current/next period
|
||||
price_max: float # Maximum price in current/next period
|
||||
price_spread: float # Price spread (max - min) in current/next period
|
||||
volatility: float # Price volatility within current/next period
|
||||
|
||||
# Price comparison (priority 4)
|
||||
period_price_diff_from_daily_min: float # Difference from daily min (minor currency)
|
||||
period_price_diff_from_daily_min: float # Difference from daily min
|
||||
period_price_diff_from_daily_min_pct: float # Difference from daily min (%)
|
||||
|
||||
# Detail information (priority 5)
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ if TYPE_CHECKING:
|
|||
from custom_components.tibber_prices.config_flow_handlers.schemas import (
|
||||
get_best_price_schema,
|
||||
get_chart_data_export_schema,
|
||||
get_display_settings_schema,
|
||||
get_options_init_schema,
|
||||
get_peak_price_schema,
|
||||
get_price_rating_schema,
|
||||
|
|
@ -69,15 +70,16 @@ class TibberPricesOptionsFlowHandler(OptionsFlow):
|
|||
"""Handle options for tibber_prices entries."""
|
||||
|
||||
# Step progress tracking
|
||||
_TOTAL_STEPS: ClassVar[int] = 7
|
||||
_TOTAL_STEPS: ClassVar[int] = 8
|
||||
_STEP_INFO: ClassVar[dict[str, int]] = {
|
||||
"init": 1,
|
||||
"current_interval_price_rating": 2,
|
||||
"volatility": 3,
|
||||
"best_price": 4,
|
||||
"peak_price": 5,
|
||||
"price_trend": 6,
|
||||
"chart_data_export": 7,
|
||||
"display_settings": 2,
|
||||
"current_interval_price_rating": 3,
|
||||
"volatility": 4,
|
||||
"best_price": 5,
|
||||
"peak_price": 6,
|
||||
"price_trend": 7,
|
||||
"chart_data_export": 8,
|
||||
}
|
||||
|
||||
def __init__(self) -> None:
|
||||
|
|
@ -170,7 +172,7 @@ class TibberPricesOptionsFlowHandler(OptionsFlow):
|
|||
|
||||
if user_input is not None:
|
||||
self._options.update(user_input)
|
||||
return await self.async_step_current_interval_price_rating()
|
||||
return await self.async_step_display_settings()
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="init",
|
||||
|
|
@ -181,6 +183,26 @@ class TibberPricesOptionsFlowHandler(OptionsFlow):
|
|||
},
|
||||
)
|
||||
|
||||
async def async_step_display_settings(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
|
||||
"""Configure currency display settings."""
|
||||
# Get currency from coordinator data (if available)
|
||||
# During options flow setup, integration might not be fully loaded yet
|
||||
currency_code = None
|
||||
if DOMAIN in self.hass.data and self.config_entry.entry_id in self.hass.data[DOMAIN]:
|
||||
tibber_data = self.hass.data[DOMAIN][self.config_entry.entry_id]
|
||||
if tibber_data.coordinator.data:
|
||||
currency_code = tibber_data.coordinator.data.get("currency")
|
||||
|
||||
if user_input is not None:
|
||||
self._options.update(user_input)
|
||||
return await self.async_step_current_interval_price_rating()
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="display_settings",
|
||||
data_schema=get_display_settings_schema(self.config_entry.options, currency_code),
|
||||
description_placeholders=self._get_step_description_placeholders("display_settings"),
|
||||
)
|
||||
|
||||
async def async_step_current_interval_price_rating(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ from custom_components.tibber_prices.const import (
|
|||
CONF_BEST_PRICE_MAX_LEVEL_GAP_COUNT,
|
||||
CONF_BEST_PRICE_MIN_DISTANCE_FROM_AVG,
|
||||
CONF_BEST_PRICE_MIN_PERIOD_LENGTH,
|
||||
CONF_CURRENCY_DISPLAY_MODE,
|
||||
CONF_ENABLE_MIN_PERIODS_BEST,
|
||||
CONF_ENABLE_MIN_PERIODS_PEAK,
|
||||
CONF_EXTENDED_DESCRIPTIONS,
|
||||
|
|
@ -67,6 +68,8 @@ from custom_components.tibber_prices.const import (
|
|||
DEFAULT_VOLATILITY_THRESHOLD_HIGH,
|
||||
DEFAULT_VOLATILITY_THRESHOLD_MODERATE,
|
||||
DEFAULT_VOLATILITY_THRESHOLD_VERY_HIGH,
|
||||
DISPLAY_MODE_BASE,
|
||||
DISPLAY_MODE_SUBUNIT,
|
||||
MAX_GAP_COUNT,
|
||||
MAX_MIN_PERIOD_LENGTH,
|
||||
MAX_MIN_PERIODS,
|
||||
|
|
@ -89,6 +92,7 @@ from custom_components.tibber_prices.const import (
|
|||
MIN_VOLATILITY_THRESHOLD_MODERATE,
|
||||
MIN_VOLATILITY_THRESHOLD_VERY_HIGH,
|
||||
PEAK_PRICE_MIN_LEVEL_OPTIONS,
|
||||
get_default_currency_display,
|
||||
)
|
||||
from homeassistant.const import CONF_ACCESS_TOKEN
|
||||
from homeassistant.data_entry_flow import section
|
||||
|
|
@ -226,6 +230,31 @@ def get_options_init_schema(options: Mapping[str, Any]) -> vol.Schema:
|
|||
)
|
||||
|
||||
|
||||
def get_display_settings_schema(options: Mapping[str, Any], currency_code: str | None) -> vol.Schema:
|
||||
"""Return schema for display settings configuration."""
|
||||
default_display_mode = get_default_currency_display(currency_code)
|
||||
|
||||
return vol.Schema(
|
||||
{
|
||||
vol.Optional(
|
||||
CONF_CURRENCY_DISPLAY_MODE,
|
||||
default=str(
|
||||
options.get(
|
||||
CONF_CURRENCY_DISPLAY_MODE,
|
||||
default_display_mode,
|
||||
)
|
||||
),
|
||||
): SelectSelector(
|
||||
SelectSelectorConfig(
|
||||
options=[DISPLAY_MODE_BASE, DISPLAY_MODE_SUBUNIT],
|
||||
mode=SelectSelectorMode.DROPDOWN,
|
||||
translation_key="currency_display_mode",
|
||||
),
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def get_price_rating_schema(options: Mapping[str, Any]) -> vol.Schema:
|
||||
"""Return schema for price rating thresholds configuration."""
|
||||
return vol.Schema(
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
"""Constants for the Tibber Price Analytics integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from collections.abc import Sequence
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import aiofiles
|
||||
|
||||
|
|
@ -14,7 +15,12 @@ from homeassistant.const import (
|
|||
UnitOfPower,
|
||||
UnitOfTime,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Sequence
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
DOMAIN = "tibber_prices"
|
||||
LOGGER = logging.getLogger(__package__)
|
||||
|
|
@ -192,9 +198,9 @@ def get_currency_info(currency_code: str | None) -> tuple[str, str, str]:
|
|||
return CURRENCY_INFO.get(currency_code.upper(), CURRENCY_INFO["EUR"])
|
||||
|
||||
|
||||
def format_price_unit_major(currency_code: str | None) -> str:
|
||||
def format_price_unit_base(currency_code: str | None) -> str:
|
||||
"""
|
||||
Format the price unit string with major currency unit (e.g., '€/kWh').
|
||||
Format the price unit string with base currency unit (e.g., '€/kWh').
|
||||
|
||||
Args:
|
||||
currency_code: ISO 4217 currency code (e.g., 'EUR', 'NOK', 'SEK')
|
||||
|
|
@ -203,13 +209,13 @@ def format_price_unit_major(currency_code: str | None) -> str:
|
|||
Formatted unit string like '€/kWh' or 'kr/kWh'
|
||||
|
||||
"""
|
||||
major_symbol, _, _ = get_currency_info(currency_code)
|
||||
return f"{major_symbol}/{UnitOfPower.KILO_WATT}{UnitOfTime.HOURS}"
|
||||
base_symbol, _, _ = get_currency_info(currency_code)
|
||||
return f"{base_symbol}/{UnitOfPower.KILO_WATT}{UnitOfTime.HOURS}"
|
||||
|
||||
|
||||
def format_price_unit_minor(currency_code: str | None) -> str:
|
||||
def format_price_unit_subunit(currency_code: str | None) -> str:
|
||||
"""
|
||||
Format the price unit string with minor currency unit (e.g., 'ct/kWh').
|
||||
Format the price unit string with subunit currency unit (e.g., 'ct/kWh').
|
||||
|
||||
Args:
|
||||
currency_code: ISO 4217 currency code (e.g., 'EUR', 'NOK', 'SEK')
|
||||
|
|
@ -218,8 +224,93 @@ def format_price_unit_minor(currency_code: str | None) -> str:
|
|||
Formatted unit string like 'ct/kWh' or 'øre/kWh'
|
||||
|
||||
"""
|
||||
_, minor_symbol, _ = get_currency_info(currency_code)
|
||||
return f"{minor_symbol}/{UnitOfPower.KILO_WATT}{UnitOfTime.HOURS}"
|
||||
_, subunit_symbol, _ = get_currency_info(currency_code)
|
||||
return f"{subunit_symbol}/{UnitOfPower.KILO_WATT}{UnitOfTime.HOURS}"
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Currency Display Mode Configuration
|
||||
# ============================================================================
|
||||
|
||||
# Configuration key for currency display mode
|
||||
CONF_CURRENCY_DISPLAY_MODE = "currency_display_mode"
|
||||
|
||||
# Display mode values
|
||||
DISPLAY_MODE_BASE = "base" # Display in base currency units (€, kr)
|
||||
DISPLAY_MODE_SUBUNIT = "subunit" # Display in subunit currency units (ct, øre)
|
||||
|
||||
# Intelligent per-currency defaults based on market analysis
|
||||
# EUR: Subunit (cents) - established convention in Germany/Netherlands
|
||||
# NOK/SEK/DKK: Base (kroner) - Scandinavian preference for whole units
|
||||
# USD/GBP: Base - international standard
|
||||
DEFAULT_CURRENCY_DISPLAY = {
|
||||
"EUR": DISPLAY_MODE_SUBUNIT,
|
||||
"NOK": DISPLAY_MODE_BASE,
|
||||
"SEK": DISPLAY_MODE_BASE,
|
||||
"DKK": DISPLAY_MODE_BASE,
|
||||
"USD": DISPLAY_MODE_BASE,
|
||||
"GBP": DISPLAY_MODE_BASE,
|
||||
}
|
||||
|
||||
|
||||
def get_default_currency_display(currency_code: str | None) -> str:
|
||||
"""
|
||||
Get intelligent default display mode for a currency.
|
||||
|
||||
Args:
|
||||
currency_code: ISO 4217 currency code (e.g., 'EUR', 'NOK')
|
||||
|
||||
Returns:
|
||||
Default display mode ('base' or 'subunit')
|
||||
|
||||
"""
|
||||
if not currency_code:
|
||||
return DISPLAY_MODE_SUBUNIT # Fallback default
|
||||
|
||||
return DEFAULT_CURRENCY_DISPLAY.get(currency_code.upper(), DISPLAY_MODE_SUBUNIT)
|
||||
|
||||
|
||||
def get_display_unit_factor(config_entry: ConfigEntry) -> int:
|
||||
"""
|
||||
Get multiplication factor for converting base to display currency.
|
||||
|
||||
Internal storage is ALWAYS in base currency (4 decimals precision).
|
||||
This function returns the conversion factor based on user configuration.
|
||||
|
||||
Args:
|
||||
config_entry: ConfigEntry with currency_display_mode option
|
||||
|
||||
Returns:
|
||||
100 for subunit currency display, 1 for base currency display
|
||||
|
||||
Example:
|
||||
price_base = 0.2534 # Internal: 0.2534 €/kWh
|
||||
factor = get_display_unit_factor(config_entry)
|
||||
display_value = round(price_base * factor, 2)
|
||||
# → 25.34 ct/kWh (subunit) or 0.25 €/kWh (base)
|
||||
|
||||
"""
|
||||
display_mode = config_entry.options.get(CONF_CURRENCY_DISPLAY_MODE, DISPLAY_MODE_SUBUNIT)
|
||||
return 100 if display_mode == DISPLAY_MODE_SUBUNIT else 1
|
||||
|
||||
|
||||
def get_display_unit_string(config_entry: ConfigEntry, currency_code: str | None) -> str:
|
||||
"""
|
||||
Get unit string for display based on configuration.
|
||||
|
||||
Args:
|
||||
config_entry: ConfigEntry with currency_display_mode option
|
||||
currency_code: ISO 4217 currency code
|
||||
|
||||
Returns:
|
||||
Formatted unit string (e.g., 'ct/kWh' or '€/kWh')
|
||||
|
||||
"""
|
||||
display_mode = config_entry.options.get(CONF_CURRENCY_DISPLAY_MODE, DISPLAY_MODE_SUBUNIT)
|
||||
|
||||
if display_mode == DISPLAY_MODE_SUBUNIT:
|
||||
return format_price_unit_subunit(currency_code)
|
||||
return format_price_unit_base(currency_code)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
|
|
|
|||
|
|
@ -213,6 +213,11 @@ class TibberPricesDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
|||
user_update_interval=timedelta(days=1),
|
||||
time=self.time,
|
||||
)
|
||||
# Create period calculator BEFORE data transformer (transformer needs it in lambda)
|
||||
self._period_calculator = TibberPricesPeriodCalculator(
|
||||
config_entry=config_entry,
|
||||
log_prefix=self._log_prefix,
|
||||
)
|
||||
self._data_transformer = TibberPricesDataTransformer(
|
||||
config_entry=config_entry,
|
||||
log_prefix=self._log_prefix,
|
||||
|
|
@ -221,10 +226,6 @@ class TibberPricesDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
|||
),
|
||||
time=self.time,
|
||||
)
|
||||
self._period_calculator = TibberPricesPeriodCalculator(
|
||||
config_entry=config_entry,
|
||||
log_prefix=self._log_prefix,
|
||||
)
|
||||
self._repair_manager = TibberPricesRepairManager(
|
||||
hass=hass,
|
||||
entry_id=config_entry.entry_id,
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ def calculate_periods(
|
|||
*,
|
||||
config: TibberPricesPeriodConfig,
|
||||
time: TibberPricesTimeService,
|
||||
config_entry: Any, # ConfigEntry type
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Calculate price periods (best or peak) from price data.
|
||||
|
|
@ -52,10 +53,11 @@ def calculate_periods(
|
|||
7. Extract period summaries (start/end times, not full price data)
|
||||
|
||||
Args:
|
||||
all_prices: All price data points from yesterday/today/tomorrow
|
||||
all_prices: All price data points from yesterday/today/tomorrow.
|
||||
config: Period configuration containing reverse_sort, flex, min_distance_from_avg,
|
||||
min_period_length, threshold_low, and threshold_high
|
||||
time: TibberPricesTimeService instance (required)
|
||||
min_period_length, threshold_low, and threshold_high.
|
||||
time: TibberPricesTimeService instance (required).
|
||||
config_entry: Config entry to get display unit configuration.
|
||||
|
||||
Returns:
|
||||
Dict with:
|
||||
|
|
@ -203,6 +205,7 @@ def calculate_periods(
|
|||
price_context,
|
||||
thresholds,
|
||||
time=time,
|
||||
config_entry=config_entry,
|
||||
)
|
||||
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -8,12 +8,15 @@ if TYPE_CHECKING:
|
|||
from datetime import datetime
|
||||
|
||||
from custom_components.tibber_prices.coordinator.time_service import TibberPricesTimeService
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
|
||||
from .types import (
|
||||
TibberPricesPeriodData,
|
||||
TibberPricesPeriodStatistics,
|
||||
TibberPricesThresholdConfig,
|
||||
)
|
||||
|
||||
from custom_components.tibber_prices.const import get_display_unit_factor
|
||||
from custom_components.tibber_prices.utils.average import calculate_median
|
||||
from custom_components.tibber_prices.utils.price import (
|
||||
aggregate_period_levels,
|
||||
|
|
@ -26,12 +29,19 @@ def calculate_period_price_diff(
|
|||
price_mean: float,
|
||||
start_time: datetime,
|
||||
price_context: dict[str, Any],
|
||||
config_entry: ConfigEntry,
|
||||
) -> tuple[float | None, float | None]:
|
||||
"""
|
||||
Calculate period price difference from daily reference (min or max).
|
||||
|
||||
Uses reference price from start day of the period for consistency.
|
||||
|
||||
Args:
|
||||
price_mean: Mean price of the period (already in display units).
|
||||
start_time: Start time of the period.
|
||||
price_context: Dictionary with ref_prices per day.
|
||||
config_entry: Config entry to get display unit configuration.
|
||||
|
||||
Returns:
|
||||
Tuple of (period_price_diff, period_price_diff_pct) or (None, None) if no reference available.
|
||||
|
||||
|
|
@ -46,14 +56,15 @@ def calculate_period_price_diff(
|
|||
if ref_price is None:
|
||||
return None, None
|
||||
|
||||
# Convert reference price to minor units (ct/øre)
|
||||
ref_price_minor = round(ref_price * 100, 2)
|
||||
period_price_diff = round(price_mean - ref_price_minor, 2)
|
||||
# Convert reference price to display units
|
||||
factor = get_display_unit_factor(config_entry)
|
||||
ref_price_display = round(ref_price * factor, 2)
|
||||
period_price_diff = round(price_mean - ref_price_display, 2)
|
||||
period_price_diff_pct = None
|
||||
if ref_price_minor != 0:
|
||||
if ref_price_display != 0:
|
||||
# CRITICAL: Use abs() for negative prices (same logic as calculate_difference_percentage)
|
||||
# Example: avg=-10, ref=-20 → diff=10, pct=10/abs(-20)*100=+50% (correctly shows more expensive)
|
||||
period_price_diff_pct = round((period_price_diff / abs(ref_price_minor)) * 100, 2)
|
||||
period_price_diff_pct = round((period_price_diff / abs(ref_price_display)) * 100, 2)
|
||||
|
||||
return period_price_diff, period_price_diff_pct
|
||||
|
||||
|
|
@ -83,21 +94,27 @@ def calculate_aggregated_rating_difference(period_price_data: list[dict]) -> flo
|
|||
return round(sum(differences) / len(differences), 2)
|
||||
|
||||
|
||||
def calculate_period_price_statistics(period_price_data: list[dict]) -> dict[str, float]:
|
||||
def calculate_period_price_statistics(
|
||||
period_price_data: list[dict],
|
||||
config_entry: ConfigEntry,
|
||||
) -> dict[str, float]:
|
||||
"""
|
||||
Calculate price statistics for a period.
|
||||
|
||||
Args:
|
||||
period_price_data: List of price data dictionaries with "total" field
|
||||
period_price_data: List of price data dictionaries with "total" field.
|
||||
config_entry: Config entry to get display unit configuration.
|
||||
|
||||
Returns:
|
||||
Dictionary with price_mean, price_median, price_min, price_max, price_spread (all in minor units: ct/øre)
|
||||
Note: price_spread is calculated based on price_mean (max - min range as percentage of mean)
|
||||
Dictionary with price_mean, price_median, price_min, price_max, price_spread (all in display units).
|
||||
Note: price_spread is calculated based on price_mean (max - min range as percentage of mean).
|
||||
|
||||
"""
|
||||
prices_minor = [round(float(p["total"]) * 100, 2) for p in period_price_data]
|
||||
# Convert prices to display units based on configuration
|
||||
factor = get_display_unit_factor(config_entry)
|
||||
prices_display = [round(float(p["total"]) * factor, 2) for p in period_price_data]
|
||||
|
||||
if not prices_minor:
|
||||
if not prices_display:
|
||||
return {
|
||||
"price_mean": 0.0,
|
||||
"price_median": 0.0,
|
||||
|
|
@ -106,11 +123,11 @@ def calculate_period_price_statistics(period_price_data: list[dict]) -> dict[str
|
|||
"price_spread": 0.0,
|
||||
}
|
||||
|
||||
price_mean = round(sum(prices_minor) / len(prices_minor), 2)
|
||||
median_value = calculate_median(prices_minor)
|
||||
price_mean = round(sum(prices_display) / len(prices_display), 2)
|
||||
median_value = calculate_median(prices_display)
|
||||
price_median = round(median_value, 2) if median_value is not None else 0.0
|
||||
price_min = round(min(prices_minor), 2)
|
||||
price_max = round(max(prices_minor), 2)
|
||||
price_min = round(min(prices_display), 2)
|
||||
price_max = round(max(prices_display), 2)
|
||||
price_spread = round(price_max - price_min, 2)
|
||||
|
||||
return {
|
||||
|
|
@ -207,13 +224,14 @@ def build_period_summary_dict(
|
|||
return summary
|
||||
|
||||
|
||||
def extract_period_summaries(
|
||||
def extract_period_summaries( # noqa: PLR0913
|
||||
periods: list[list[dict]],
|
||||
all_prices: list[dict],
|
||||
price_context: dict[str, Any],
|
||||
thresholds: TibberPricesThresholdConfig,
|
||||
*,
|
||||
time: TibberPricesTimeService,
|
||||
config_entry: ConfigEntry,
|
||||
) -> list[dict]:
|
||||
"""
|
||||
Extract complete period summaries with all aggregated attributes.
|
||||
|
|
@ -230,11 +248,12 @@ def extract_period_summaries(
|
|||
All data is pre-calculated and ready for display - no further processing needed.
|
||||
|
||||
Args:
|
||||
periods: List of periods, where each period is a list of interval dictionaries
|
||||
all_prices: All price data from the API (enriched with level, difference, rating_level)
|
||||
price_context: Dictionary with ref_prices and avg_prices per day
|
||||
thresholds: Threshold configuration for calculations
|
||||
time: TibberPricesTimeService instance (required)
|
||||
periods: List of periods, where each period is a list of interval dictionaries.
|
||||
all_prices: All price data from the API (enriched with level, difference, rating_level).
|
||||
price_context: Dictionary with ref_prices and avg_prices per day.
|
||||
thresholds: Threshold configuration for calculations.
|
||||
time: TibberPricesTimeService instance (required).
|
||||
config_entry: Config entry to get display unit configuration.
|
||||
|
||||
"""
|
||||
from .types import ( # noqa: PLC0415 - Avoid circular import
|
||||
|
|
@ -292,12 +311,12 @@ def extract_period_summaries(
|
|||
thresholds.threshold_high,
|
||||
)
|
||||
|
||||
# Calculate price statistics (in minor units: ct/øre)
|
||||
price_stats = calculate_period_price_statistics(period_price_data)
|
||||
# Calculate price statistics (in display units based on configuration)
|
||||
price_stats = calculate_period_price_statistics(period_price_data, config_entry)
|
||||
|
||||
# Calculate period price difference from daily reference
|
||||
period_price_diff, period_price_diff_pct = calculate_period_price_diff(
|
||||
price_stats["price_mean"], start_time, price_context
|
||||
price_stats["price_mean"], start_time, price_context, config_entry
|
||||
)
|
||||
|
||||
# Extract prices for volatility calculation (coefficient of variation)
|
||||
|
|
|
|||
|
|
@ -146,6 +146,7 @@ def calculate_periods_with_relaxation( # noqa: PLR0913, PLR0915 - Per-day relax
|
|||
max_relaxation_attempts: int,
|
||||
should_show_callback: Callable[[str | None], bool],
|
||||
time: TibberPricesTimeService,
|
||||
config_entry: Any, # ConfigEntry type
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Calculate periods with optional per-day filter relaxation.
|
||||
|
|
@ -170,7 +171,8 @@ def calculate_periods_with_relaxation( # noqa: PLR0913, PLR0915 - Per-day relax
|
|||
should_show_callback: Callback function(level_override) -> bool
|
||||
Returns True if periods should be shown with given filter overrides. Pass None
|
||||
to use original configured filter values.
|
||||
time: TibberPricesTimeService instance (required)
|
||||
time: TibberPricesTimeService instance (required).
|
||||
config_entry: Config entry to get display unit configuration.
|
||||
|
||||
Returns:
|
||||
Dict with same format as calculate_periods() output:
|
||||
|
|
@ -275,7 +277,7 @@ def calculate_periods_with_relaxation( # noqa: PLR0913, PLR0915 - Per-day relax
|
|||
|
||||
# === BASELINE CALCULATION (process ALL prices together, including yesterday) ===
|
||||
# Periods that ended yesterday will be filtered out later by filter_periods_by_end_date()
|
||||
baseline_result = calculate_periods(all_prices, config=config, time=time)
|
||||
baseline_result = calculate_periods(all_prices, config=config, time=time, config_entry=config_entry)
|
||||
all_periods = baseline_result["periods"]
|
||||
|
||||
# Count periods per day for min_periods check
|
||||
|
|
@ -320,6 +322,7 @@ def calculate_periods_with_relaxation( # noqa: PLR0913, PLR0915 - Per-day relax
|
|||
should_show_callback=should_show_callback,
|
||||
baseline_periods=all_periods,
|
||||
time=time,
|
||||
config_entry=config_entry,
|
||||
)
|
||||
|
||||
all_periods = relaxed_result["periods"]
|
||||
|
|
@ -379,6 +382,7 @@ def relax_all_prices( # noqa: PLR0913 - Comprehensive filter relaxation require
|
|||
baseline_periods: list[dict],
|
||||
*,
|
||||
time: TibberPricesTimeService,
|
||||
config_entry: Any, # ConfigEntry type
|
||||
) -> tuple[dict[str, Any], dict[str, Any]]:
|
||||
"""
|
||||
Relax filters for all prices until min_periods per day is reached.
|
||||
|
|
@ -389,13 +393,14 @@ def relax_all_prices( # noqa: PLR0913 - Comprehensive filter relaxation require
|
|||
(or max attempts exhausted).
|
||||
|
||||
Args:
|
||||
all_prices: All price intervals (yesterday+today+tomorrow)
|
||||
config: Base period configuration
|
||||
min_periods: Target number of periods PER DAY
|
||||
max_relaxation_attempts: Maximum flex levels to try
|
||||
should_show_callback: Callback to check if a flex level should be shown
|
||||
baseline_periods: Baseline periods (before relaxation)
|
||||
time: TibberPricesTimeService instance
|
||||
all_prices: All price intervals (yesterday+today+tomorrow).
|
||||
config: Base period configuration.
|
||||
min_periods: Target number of periods PER DAY.
|
||||
max_relaxation_attempts: Maximum flex levels to try.
|
||||
should_show_callback: Callback to check if a flex level should be shown.
|
||||
baseline_periods: Baseline periods (before relaxation).
|
||||
time: TibberPricesTimeService instance.
|
||||
config_entry: Config entry to get display unit configuration.
|
||||
|
||||
Returns:
|
||||
Tuple of (result_dict, metadata_dict)
|
||||
|
|
@ -460,7 +465,7 @@ def relax_all_prices( # noqa: PLR0913 - Comprehensive filter relaxation require
|
|||
)
|
||||
|
||||
# Process ALL prices together (allows midnight crossing)
|
||||
result = calculate_periods(all_prices, config=relaxed_config, time=time)
|
||||
result = calculate_periods(all_prices, config=relaxed_config, time=time, config_entry=config_entry)
|
||||
new_periods = result["periods"]
|
||||
|
||||
_LOGGER_DETAILS.debug(
|
||||
|
|
|
|||
|
|
@ -657,6 +657,7 @@ class TibberPricesPeriodCalculator:
|
|||
level_override=lvl,
|
||||
),
|
||||
time=self.time,
|
||||
config_entry=self.config_entry,
|
||||
)
|
||||
else:
|
||||
best_periods = {
|
||||
|
|
@ -729,6 +730,7 @@ class TibberPricesPeriodCalculator:
|
|||
level_override=lvl,
|
||||
),
|
||||
time=self.time,
|
||||
config_entry=self.config_entry,
|
||||
)
|
||||
else:
|
||||
peak_periods = {
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@
|
|||
"long_description": "Zeigt den aktuellen Preis pro kWh von deinem Tibber-Abonnement an",
|
||||
"usage_tips": "Nutze dies, um Preise zu verfolgen oder Automatisierungen zu erstellen, die bei günstigem Strom ausgeführt werden"
|
||||
},
|
||||
"current_interval_price_major": {
|
||||
"current_interval_price_base": {
|
||||
"description": "Aktueller Strompreis in Hauptwährung (EUR/kWh, NOK/kWh, etc.) für Energie-Dashboard",
|
||||
"long_description": "Zeigt den aktuellen Preis pro kWh in Hauptwährungseinheiten an (z.B. EUR/kWh statt ct/kWh, NOK/kWh statt øre/kWh). Dieser Sensor ist speziell für die Verwendung mit dem Energie-Dashboard von Home Assistant konzipiert, das Preise in Standard-Währungseinheiten benötigt.",
|
||||
"usage_tips": "Verwende diesen Sensor beim Konfigurieren des Energie-Dashboards unter Einstellungen → Dashboards → Energie. Wähle diesen Sensor als 'Entität mit dem aktuellen Preis' aus, um deine Energiekosten automatisch zu berechnen. Das Energie-Dashboard multipliziert deinen Energieverbrauch (kWh) mit diesem Preis, um die Gesamtkosten anzuzeigen."
|
||||
|
|
@ -452,7 +452,7 @@
|
|||
"chart_metadata": {
|
||||
"description": "Leichtgewichtige Metadaten für Diagrammkonfiguration",
|
||||
"long_description": "Liefert wesentliche Diagrammkonfigurationswerte als Sensor-Attribute. Nützlich für jede Diagrammkarte, die Y-Achsen-Grenzen benötigt. Der Sensor ruft get_chartdata im Nur-Metadaten-Modus auf (keine Datenverarbeitung) und extrahiert: yaxis_min, yaxis_max (vorgeschlagener Y-Achsenbereich für optimale Skalierung). Der Status spiegelt das Service-Call-Ergebnis wider: 'ready' bei Erfolg, 'error' bei Fehler, 'pending' während der Initialisierung.",
|
||||
"usage_tips": "Konfiguriere über configuration.yaml unter tibber_prices.chart_metadata_config (optional: day, minor_currency, resolution). Der Sensor aktualisiert sich automatisch bei Preisdatenänderungen. Greife auf Metadaten aus Attributen zu: yaxis_min, yaxis_max. Verwende mit config-template-card oder jedem Tool, das Entity-Attribute liest - perfekt für dynamische Diagrammkonfiguration ohne manuelle Berechnungen."
|
||||
"usage_tips": "Konfiguriere über configuration.yaml unter tibber_prices.chart_metadata_config (optional: day, subunit_currency, resolution). Der Sensor aktualisiert sich automatisch bei Preisdatenänderungen. Greife auf Metadaten aus Attributen zu: yaxis_min, yaxis_max. Verwende mit config-template-card oder jedem Tool, das Entity-Attribute liest - perfekt für dynamische Diagrammkonfiguration ohne manuelle Berechnungen."
|
||||
}
|
||||
},
|
||||
"binary_sensor": {
|
||||
|
|
|
|||
|
|
@ -20,9 +20,9 @@
|
|||
"long_description": "Shows the current price per kWh from your Tibber subscription",
|
||||
"usage_tips": "Use this to track prices or to create automations that run when electricity is cheap"
|
||||
},
|
||||
"current_interval_price_major": {
|
||||
"description": "Current electricity price in major currency (EUR/kWh, NOK/kWh, etc.) for Energy Dashboard",
|
||||
"long_description": "Shows the current price per kWh in major currency units (e.g., EUR/kWh instead of ct/kWh, NOK/kWh instead of øre/kWh). This sensor is specifically designed for use with Home Assistant's Energy Dashboard, which requires prices in standard currency units.",
|
||||
"current_interval_price_base": {
|
||||
"description": "Current electricity price in base currency (EUR/kWh, NOK/kWh, etc.) for Energy Dashboard",
|
||||
"long_description": "Shows the current price per kWh in base currency units (e.g., EUR/kWh instead of ct/kWh, NOK/kWh instead of øre/kWh). This sensor is specifically designed for use with Home Assistant's Energy Dashboard, which requires prices in standard currency units.",
|
||||
"usage_tips": "Use this sensor when configuring the Energy Dashboard under Settings → Dashboards → Energy. Select this sensor as the 'Entity with current price' to automatically calculate your energy costs. The Energy Dashboard multiplies your energy consumption (kWh) by this price to show total costs."
|
||||
},
|
||||
"next_interval_price": {
|
||||
|
|
@ -452,7 +452,7 @@
|
|||
"chart_metadata": {
|
||||
"description": "Lightweight metadata for chart configuration",
|
||||
"long_description": "Provides essential chart configuration values as sensor attributes. Useful for any chart card that needs Y-axis bounds. The sensor calls get_chartdata with metadata-only mode (no data processing) and extracts: yaxis_min, yaxis_max (suggested Y-axis range for optimal scaling). The state reflects the service call result: 'ready' when successful, 'error' on failure, 'pending' during initialization.",
|
||||
"usage_tips": "Configure via configuration.yaml under tibber_prices.chart_metadata_config (optional: day, minor_currency, resolution). The sensor automatically refreshes when price data updates. Access metadata from attributes: yaxis_min, yaxis_max. Use with config-template-card or any tool that reads entity attributes - perfect for dynamic chart configuration without manual calculations."
|
||||
"usage_tips": "Configure via configuration.yaml under tibber_prices.chart_metadata_config (optional: day, subunit_currency, resolution). The sensor automatically refreshes when price data updates. Access metadata from attributes: yaxis_min, yaxis_max. Use with config-template-card or any tool that reads entity attributes - perfect for dynamic chart configuration without manual calculations."
|
||||
}
|
||||
},
|
||||
"binary_sensor": {
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@
|
|||
"long_description": "Viser nåværende pris per kWh fra ditt Tibber-abonnement",
|
||||
"usage_tips": "Bruk dette til å spore priser eller lage automatiseringer som kjører når strøm er billig"
|
||||
},
|
||||
"current_interval_price_major": {
|
||||
"current_interval_price_base": {
|
||||
"description": "Nåværende elektrisitetspris i hovedvaluta (EUR/kWh, NOK/kWh, osv.) for Energi-dashboard",
|
||||
"long_description": "Viser nåværende pris per kWh i hovedvalutaenheter (f.eks. EUR/kWh i stedet for ct/kWh, NOK/kWh i stedet for øre/kWh). Denne sensoren er spesielt designet for bruk med Home Assistants Energi-dashboard, som krever priser i standard valutaenheter.",
|
||||
"usage_tips": "Bruk denne sensoren når du konfigurerer Energi-dashboardet under Innstillinger → Dashbord → Energi. Velg denne sensoren som 'Entitet med nåværende pris' for automatisk å beregne energikostnadene. Energi-dashboardet multipliserer energiforbruket ditt (kWh) med denne prisen for å vise totale kostnader."
|
||||
|
|
@ -452,7 +452,7 @@
|
|||
"chart_metadata": {
|
||||
"description": "Lettvekts metadata for diagramkonfigurasjon",
|
||||
"long_description": "Gir essensielle diagramkonfigurasjonsverdier som sensorattributter. Nyttig for ethvert diagramkort som trenger Y-aksegrenser. Sensoren kaller get_chartdata med kun-metadata-modus (ingen databehandling) og trekker ut: yaxis_min, yaxis_max (foreslått Y-akseområde for optimal skalering). Status reflekterer tjenestekallresultatet: 'ready' ved suksess, 'error' ved feil, 'pending' under initialisering.",
|
||||
"usage_tips": "Konfigurer via configuration.yaml under tibber_prices.chart_metadata_config (valgfritt: day, minor_currency, resolution). Sensoren oppdateres automatisk når prisdata endres. Få tilgang til metadata fra attributter: yaxis_min, yaxis_max. Bruk med config-template-card eller ethvert verktøy som leser entitetsattributter - perfekt for dynamisk diagramkonfigurasjon uten manuelle beregninger."
|
||||
"usage_tips": "Konfigurer via configuration.yaml under tibber_prices.chart_metadata_config (valgfritt: day, subunit_currency, resolution). Sensoren oppdateres automatisk når prisdata endres. Få tilgang til metadata fra attributter: yaxis_min, yaxis_max. Bruk med config-template-card eller ethvert verktøy som leser entitetsattributter - perfekt for dynamisk diagramkonfigurasjon uten manuelle beregninger."
|
||||
}
|
||||
},
|
||||
"binary_sensor": {
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@
|
|||
"long_description": "Toont de huidige prijs per kWh van je Tibber-abonnement",
|
||||
"usage_tips": "Gebruik dit om prijzen bij te houden of om automatiseringen te maken die worden uitgevoerd wanneer elektriciteit goedkoop is"
|
||||
},
|
||||
"current_interval_price_major": {
|
||||
"current_interval_price_base": {
|
||||
"description": "Huidige elektriciteitsprijs in hoofdvaluta (EUR/kWh, NOK/kWh, enz.) voor Energie-dashboard",
|
||||
"long_description": "Toont de huidige prijs per kWh in hoofdvaluta-eenheden (bijv. EUR/kWh in plaats van ct/kWh, NOK/kWh in plaats van øre/kWh). Deze sensor is speciaal ontworpen voor gebruik met het Energie-dashboard van Home Assistant, dat prijzen in standaard valuta-eenheden vereist.",
|
||||
"usage_tips": "Gebruik deze sensor bij het configureren van het Energie-dashboard onder Instellingen → Dashboards → Energie. Selecteer deze sensor als 'Entiteit met huidige prijs' om automatisch je energiekosten te berekenen. Het Energie-dashboard vermenigvuldigt je energieverbruik (kWh) met deze prijs om totale kosten weer te geven."
|
||||
|
|
@ -452,7 +452,7 @@
|
|||
"chart_metadata": {
|
||||
"description": "Lichtgewicht metadata voor diagramconfiguratie",
|
||||
"long_description": "Biedt essentiële diagramconfiguratiewaarden als sensorattributen. Nuttig voor elke grafiekkaart die Y-as-grenzen nodig heeft. De sensor roept get_chartdata aan in alleen-metadata-modus (geen dataverwerking) en extraheert: yaxis_min, yaxis_max (gesuggereerd Y-asbereik voor optimale schaling). De status weerspiegelt het service-aanroepresultaat: 'ready' bij succes, 'error' bij fouten, 'pending' tijdens initialisatie.",
|
||||
"usage_tips": "Configureer via configuration.yaml onder tibber_prices.chart_metadata_config (optioneel: day, minor_currency, resolution). De sensor wordt automatisch bijgewerkt bij prijsgegevenswijzigingen. Krijg toegang tot metadata vanuit attributen: yaxis_min, yaxis_max. Gebruik met config-template-card of elk hulpmiddel dat entiteitsattributen leest - perfect voor dynamische diagramconfiguratie zonder handmatige berekeningen."
|
||||
"usage_tips": "Configureer via configuration.yaml onder tibber_prices.chart_metadata_config (optioneel: day, subunit_currency, resolution). De sensor wordt automatisch bijgewerkt bij prijsgegevenswijzigingen. Krijg toegang tot metadata vanuit attributen: yaxis_min, yaxis_max. Gebruik met config-template-card of elk hulpmiddel dat entiteitsattributen leest - perfect voor dynamische diagramconfiguratie zonder handmatige berekeningen."
|
||||
}
|
||||
},
|
||||
"binary_sensor": {
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@
|
|||
"long_description": "Visar nuvarande pris per kWh från ditt Tibber-abonnemang",
|
||||
"usage_tips": "Använd detta för att spåra priser eller skapa automationer som körs när el är billig"
|
||||
},
|
||||
"current_interval_price_major": {
|
||||
"current_interval_price_base": {
|
||||
"description": "Nuvarande elpris i huvudvaluta (EUR/kWh, NOK/kWh, osv.) för Energipanelen",
|
||||
"long_description": "Visar nuvarande pris per kWh i huvudvaluta-enheter (t.ex. EUR/kWh istället för ct/kWh, NOK/kWh istället för øre/kWh). Denna sensor är speciellt utformad för användning med Home Assistants Energipanel, som kräver priser i standardvalutaenheter.",
|
||||
"usage_tips": "Använd denna sensor när du konfigurerar Energipanelen under Inställningar → Instrumentpaneler → Energi. Välj denna sensor som 'Entitet med nuvarande pris' för att automatiskt beräkna dina energikostnader. Energipanelen multiplicerar din energiförbrukning (kWh) med detta pris för att visa totala kostnader."
|
||||
|
|
@ -452,7 +452,7 @@
|
|||
"chart_metadata": {
|
||||
"description": "Lättviktig metadata för diagramkonfiguration",
|
||||
"long_description": "Tillhandahåller väsentliga diagramkonfigurationsvärden som sensorattribut. Användbart för vilket diagramkort som helst som behöver Y-axelgränser. Sensorn anropar get_chartdata med endast-metadata-läge (ingen databehandling) och extraherar: yaxis_min, yaxis_max (föreslagen Y-axelomfång för optimal skalning). Statusen återspeglar tjänstanropsresultatet: 'ready' vid framgång, 'error' vid fel, 'pending' under initialisering.",
|
||||
"usage_tips": "Konfigurera via configuration.yaml under tibber_prices.chart_metadata_config (valfritt: day, minor_currency, resolution). Sensorn uppdateras automatiskt vid pris dataändringar. Få tillgång till metadata från attribut: yaxis_min, yaxis_max. Använd med config-template-card eller vilket verktyg som helst som läser entitetsattribut - perfekt för dynamisk diagramkonfiguration utan manuella beräkningar."
|
||||
"usage_tips": "Konfigurera via configuration.yaml under tibber_prices.chart_metadata_config (valfritt: day, subunit_currency, resolution). Sensorn uppdateras automatiskt vid pris dataändringar. Få tillgång till metadata från attribut: yaxis_min, yaxis_max. Använd med config-template-card eller vilket verktyg som helst som läser entitetsattribut - perfekt för dynamisk diagramkonfiguration utan manuella beräkningar."
|
||||
}
|
||||
},
|
||||
"binary_sensor": {
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
Common helper functions for entities across platforms.
|
||||
|
||||
This module provides utility functions used by both sensor and binary_sensor platforms:
|
||||
- Price value conversion (major/minor currency units)
|
||||
- Price value conversion (major/subunit currency units)
|
||||
- Translation helpers (price levels, ratings)
|
||||
- Time-based calculations (rolling hour center index)
|
||||
|
||||
|
|
@ -14,28 +14,52 @@ from __future__ import annotations
|
|||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from custom_components.tibber_prices.const import get_price_level_translation
|
||||
from custom_components.tibber_prices.const import get_display_unit_factor, get_price_level_translation
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from datetime import datetime
|
||||
|
||||
from custom_components.tibber_prices.coordinator.time_service import TibberPricesTimeService
|
||||
from custom_components.tibber_prices.data import TibberPricesConfigEntry
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
|
||||
def get_price_value(price: float, *, in_euro: bool) -> float:
|
||||
def get_price_value(
|
||||
price: float,
|
||||
*,
|
||||
in_euro: bool | None = None,
|
||||
config_entry: ConfigEntry | TibberPricesConfigEntry | None = None,
|
||||
) -> float:
|
||||
"""
|
||||
Convert price based on unit.
|
||||
|
||||
NOTE: This function supports two modes for backward compatibility:
|
||||
1. Legacy mode: in_euro=True/False (hardcoded conversion)
|
||||
2. New mode: config_entry (config-driven conversion)
|
||||
|
||||
New code should use get_display_unit_factor(config_entry) directly.
|
||||
|
||||
Args:
|
||||
price: Price value to convert
|
||||
in_euro: If True, return price in euros; if False, return in cents/øre
|
||||
price: Price value to convert.
|
||||
in_euro: (Legacy) If True, return in base currency; if False, in subunit currency.
|
||||
config_entry: (New) Config entry to get display unit configuration.
|
||||
|
||||
Returns:
|
||||
Price in requested unit (euros or minor currency units)
|
||||
Price in requested unit (major or subunit currency units).
|
||||
|
||||
"""
|
||||
return price if in_euro else round((price * 100), 2)
|
||||
# Legacy mode: use in_euro parameter
|
||||
if in_euro is not None:
|
||||
return price if in_euro else round(price * 100, 2)
|
||||
|
||||
# New mode: use config_entry
|
||||
if config_entry is not None:
|
||||
factor = get_display_unit_factor(config_entry)
|
||||
return round(price * factor, 2)
|
||||
|
||||
# Fallback: default to subunit currency (backward compatibility)
|
||||
return round(price * 100, 2)
|
||||
|
||||
|
||||
def translate_level(hass: HomeAssistant, level: str) -> str:
|
||||
|
|
|
|||
|
|
@ -17,6 +17,11 @@ from __future__ import annotations
|
|||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from custom_components.tibber_prices.const import (
|
||||
CONF_CURRENCY_DISPLAY_MODE,
|
||||
DISPLAY_MODE_BASE,
|
||||
)
|
||||
|
||||
from .core import TibberPricesSensor
|
||||
from .definitions import ENTITY_DESCRIPTIONS
|
||||
|
||||
|
|
@ -34,10 +39,22 @@ async def async_setup_entry(
|
|||
"""Set up Tibber Prices sensor based on a config entry."""
|
||||
coordinator = entry.runtime_data.coordinator
|
||||
|
||||
# Get display mode from config
|
||||
display_mode = entry.options.get(CONF_CURRENCY_DISPLAY_MODE, DISPLAY_MODE_BASE)
|
||||
|
||||
# Filter entity descriptions based on display mode
|
||||
# Skip current_interval_price_base if user configured major display
|
||||
# (regular current_interval_price already shows major units)
|
||||
entities_to_create = [
|
||||
entity_description
|
||||
for entity_description in ENTITY_DESCRIPTIONS
|
||||
if not (entity_description.key == "current_interval_price_base" and display_mode == DISPLAY_MODE_BASE)
|
||||
]
|
||||
|
||||
async_add_entities(
|
||||
TibberPricesSensor(
|
||||
coordinator=coordinator,
|
||||
entity_description=entity_description,
|
||||
)
|
||||
for entity_description in ENTITY_DESCRIPTIONS
|
||||
for entity_description in entities_to_create
|
||||
)
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ from __future__ import annotations
|
|||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from custom_components.tibber_prices.const import get_display_unit_factor
|
||||
from custom_components.tibber_prices.coordinator.helpers import get_intervals_for_day_offsets
|
||||
|
||||
if TYPE_CHECKING:
|
||||
|
|
@ -85,17 +86,19 @@ def get_future_prices(
|
|||
max_intervals: int | None = None,
|
||||
*,
|
||||
time: TibberPricesTimeService,
|
||||
config_entry: TibberPricesConfigEntry,
|
||||
) -> list[dict] | None:
|
||||
"""
|
||||
Get future price data for multiple upcoming intervals.
|
||||
|
||||
Args:
|
||||
coordinator: The data update coordinator
|
||||
max_intervals: Maximum number of future intervals to return
|
||||
time: TibberPricesTimeService instance (required)
|
||||
coordinator: The data update coordinator.
|
||||
max_intervals: Maximum number of future intervals to return.
|
||||
time: TibberPricesTimeService instance (required).
|
||||
config_entry: Config entry to get display unit configuration.
|
||||
|
||||
Returns:
|
||||
List of upcoming price intervals with timestamps and prices
|
||||
List of upcoming price intervals with timestamps and prices.
|
||||
|
||||
"""
|
||||
if not coordinator.data:
|
||||
|
|
@ -136,12 +139,17 @@ def get_future_prices(
|
|||
else:
|
||||
day_key = "unknown"
|
||||
|
||||
# Convert to display currency unit based on configuration
|
||||
price_major = float(price_data["total"])
|
||||
factor = get_display_unit_factor(config_entry)
|
||||
price_display = round(price_major * factor, 2)
|
||||
|
||||
future_prices.append(
|
||||
{
|
||||
"interval_start": starts_at,
|
||||
"interval_end": interval_end,
|
||||
"price": float(price_data["total"]),
|
||||
"price_minor": round(float(price_data["total"]) * 100, 2),
|
||||
"price": price_major,
|
||||
"price_minor": price_display,
|
||||
"level": price_data.get("level", "NORMAL"),
|
||||
"rating": price_data.get("difference", None),
|
||||
"rating_level": price_data.get("rating_level"),
|
||||
|
|
|
|||
|
|
@ -62,7 +62,7 @@ class TibberPricesDailyStatCalculator(TibberPricesBaseCalculator):
|
|||
stat_func: Statistical function (min, max, or lambda for avg/median).
|
||||
|
||||
Returns:
|
||||
Price value in minor currency units (cents/øre), or None if unavailable.
|
||||
Price value in subunit currency units (cents/øre), or None if unavailable.
|
||||
For average functions: tuple of (avg, median) where median may be None.
|
||||
For min/max functions: single float value.
|
||||
|
||||
|
|
@ -107,9 +107,13 @@ class TibberPricesDailyStatCalculator(TibberPricesBaseCalculator):
|
|||
# Store the interval (for avg, use first interval as reference)
|
||||
if price_intervals:
|
||||
self._last_extreme_interval = price_intervals[0]["interval"]
|
||||
# Convert both to minor currency units
|
||||
avg_result = round(get_price_value(value, in_euro=False), 2)
|
||||
median_result = round(get_price_value(median, in_euro=False), 2) if median is not None else None
|
||||
# Convert to display currency units based on config
|
||||
avg_result = round(get_price_value(value, config_entry=self.coordinator.config_entry), 2)
|
||||
median_result = (
|
||||
round(get_price_value(median, config_entry=self.coordinator.config_entry), 2)
|
||||
if median is not None
|
||||
else None
|
||||
)
|
||||
return avg_result, median_result
|
||||
|
||||
# Single value result (min/max functions)
|
||||
|
|
@ -121,8 +125,8 @@ class TibberPricesDailyStatCalculator(TibberPricesBaseCalculator):
|
|||
self._last_extreme_interval = pi["interval"]
|
||||
break
|
||||
|
||||
# Always return in minor currency units (cents/øre) with 2 decimals
|
||||
result = get_price_value(value, in_euro=False)
|
||||
# Return in configured display currency units with 2 decimals
|
||||
result = get_price_value(value, config_entry=self.coordinator.config_entry)
|
||||
return round(result, 2)
|
||||
|
||||
def get_daily_aggregated_value(
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ from __future__ import annotations
|
|||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from custom_components.tibber_prices.const import get_display_unit_factor
|
||||
|
||||
from .base import TibberPricesBaseCalculator
|
||||
|
||||
if TYPE_CHECKING:
|
||||
|
|
@ -34,7 +36,7 @@ class TibberPricesIntervalCalculator(TibberPricesBaseCalculator):
|
|||
self._last_rating_level: str | None = None
|
||||
self._last_rating_difference: float | None = None
|
||||
|
||||
def get_interval_value(
|
||||
def get_interval_value( # noqa: PLR0911
|
||||
self,
|
||||
*,
|
||||
interval_offset: int,
|
||||
|
|
@ -68,7 +70,11 @@ class TibberPricesIntervalCalculator(TibberPricesBaseCalculator):
|
|||
if price is None:
|
||||
return None
|
||||
price = float(price)
|
||||
return price if in_euro else round(price * 100, 2)
|
||||
# Return in base currency if in_euro=True, otherwise in display unit
|
||||
if in_euro:
|
||||
return price
|
||||
factor = get_display_unit_factor(self.config_entry)
|
||||
return round(price * factor, 2)
|
||||
|
||||
if value_type == "level":
|
||||
level = self.safe_get_from_interval(interval_data, "level")
|
||||
|
|
|
|||
|
|
@ -108,7 +108,7 @@ class TibberPricesRollingHourCalculator(TibberPricesBaseCalculator):
|
|||
|
||||
# Handle price aggregation - return tuple directly
|
||||
if value_type == "price":
|
||||
return aggregate_price_data(window_data)
|
||||
return aggregate_price_data(window_data, self.config_entry)
|
||||
|
||||
# Map other value types to aggregation functions
|
||||
aggregators = {
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ Caching strategy:
|
|||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from custom_components.tibber_prices.const import get_display_unit_factor
|
||||
from custom_components.tibber_prices.coordinator.helpers import get_intervals_for_day_offsets
|
||||
from custom_components.tibber_prices.utils.average import calculate_next_n_hours_avg
|
||||
from custom_components.tibber_prices.utils.price import (
|
||||
|
|
@ -133,11 +134,14 @@ class TibberPricesTrendCalculator(TibberPricesBaseCalculator):
|
|||
"stable": "var(--state-icon-color)", # Default gray for stable prices
|
||||
}.get(trend_state, "var(--state-icon-color)")
|
||||
|
||||
# Convert prices to display currency unit based on configuration
|
||||
factor = get_display_unit_factor(self.config_entry)
|
||||
|
||||
# Store attributes in sensor-specific dictionary AND cache the trend value
|
||||
self._trend_attributes = {
|
||||
"timestamp": next_interval_start,
|
||||
f"trend_{hours}h_%": round(diff_pct, 1),
|
||||
f"next_{hours}h_avg": round(future_avg * 100, 2),
|
||||
f"next_{hours}h_avg": round(future_avg * factor, 2),
|
||||
"interval_count": lookahead_intervals,
|
||||
"threshold_rising": threshold_rising,
|
||||
"threshold_falling": threshold_falling,
|
||||
|
|
@ -149,7 +153,7 @@ class TibberPricesTrendCalculator(TibberPricesBaseCalculator):
|
|||
# Get second half average for longer periods
|
||||
later_half_avg = self._calculate_later_half_average(hours, next_interval_start)
|
||||
if later_half_avg is not None:
|
||||
self._trend_attributes[f"second_half_{hours}h_avg"] = round(later_half_avg * 100, 2)
|
||||
self._trend_attributes[f"second_half_{hours}h_avg"] = round(later_half_avg * factor, 2)
|
||||
|
||||
# Calculate incremental change: how much does the later half differ from current?
|
||||
# CRITICAL: Use abs() for negative prices and allow calculation for all non-zero prices
|
||||
|
|
@ -693,13 +697,16 @@ class TibberPricesTrendCalculator(TibberPricesBaseCalculator):
|
|||
time = self.coordinator.time
|
||||
minutes_until = int(time.minutes_until(interval_start))
|
||||
|
||||
# Convert prices to display currency unit
|
||||
factor = get_display_unit_factor(self.config_entry)
|
||||
|
||||
self._trend_change_attributes = {
|
||||
"direction": trend_state,
|
||||
"from_direction": current_trend_state,
|
||||
"minutes_until_change": minutes_until,
|
||||
"current_price_now": round(float(current_interval["total"]) * 100, 2),
|
||||
"price_at_change": round(current_price * 100, 2),
|
||||
"avg_after_change": round(future_avg * 100, 2),
|
||||
"current_price_now": round(float(current_interval["total"]) * factor, 2),
|
||||
"price_at_change": round(current_price * factor, 2),
|
||||
"avg_after_change": round(future_avg * factor, 2),
|
||||
"trend_diff_%": round((future_avg - current_price) / current_price * 100, 1),
|
||||
}
|
||||
return interval_start
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ from __future__ import annotations
|
|||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from custom_components.tibber_prices.const import get_display_unit_factor
|
||||
from custom_components.tibber_prices.entity_utils import add_icon_color_attribute
|
||||
from custom_components.tibber_prices.sensor.attributes import (
|
||||
add_volatility_type_attributes,
|
||||
|
|
@ -76,19 +77,20 @@ class TibberPricesVolatilityCalculator(TibberPricesBaseCalculator):
|
|||
# Use arithmetic mean for volatility calculation (required for coefficient of variation)
|
||||
price_mean = sum(prices_to_analyze) / len(prices_to_analyze)
|
||||
|
||||
# Convert to minor currency units (ct/øre) for display
|
||||
spread_minor = spread * 100
|
||||
# Convert to display currency unit based on configuration
|
||||
factor = get_display_unit_factor(self.config_entry)
|
||||
spread_display = spread * factor
|
||||
|
||||
# Calculate volatility level with custom thresholds (pass price list, not spread)
|
||||
volatility = calculate_volatility_level(prices_to_analyze, **thresholds)
|
||||
|
||||
# Store attributes for this sensor
|
||||
self._last_volatility_attributes = {
|
||||
"price_spread": round(spread_minor, 2),
|
||||
"price_spread": round(spread_display, 2),
|
||||
"price_volatility": volatility,
|
||||
"price_min": round(price_min * 100, 2),
|
||||
"price_max": round(price_max * 100, 2),
|
||||
"price_mean": round(price_mean * 100, 2), # Mean used for volatility calculation
|
||||
"price_min": round(price_min * factor, 2),
|
||||
"price_max": round(price_max * factor, 2),
|
||||
"price_mean": round(price_mean * factor, 2), # Mean used for volatility calculation
|
||||
"interval_count": len(prices_to_analyze),
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ class TibberPricesWindow24hCalculator(TibberPricesBaseCalculator):
|
|||
stat_func: Function from average_utils (e.g., calculate_current_trailing_avg).
|
||||
|
||||
Returns:
|
||||
Price value in minor currency units (cents/øre), or None if unavailable.
|
||||
Price value in subunit currency units (cents/øre), or None if unavailable.
|
||||
For average functions: tuple of (avg, median) where median may be None.
|
||||
For min/max functions: single float value.
|
||||
|
||||
|
|
@ -51,9 +51,13 @@ class TibberPricesWindow24hCalculator(TibberPricesBaseCalculator):
|
|||
value, median = result
|
||||
if value is None:
|
||||
return None
|
||||
# Return both values converted to minor currency units
|
||||
avg_result = round(get_price_value(value, in_euro=False), 2)
|
||||
median_result = round(get_price_value(median, in_euro=False), 2) if median is not None else None
|
||||
# Convert to display currency units based on config
|
||||
avg_result = round(get_price_value(value, config_entry=self.coordinator.config_entry), 2)
|
||||
median_result = (
|
||||
round(get_price_value(median, config_entry=self.coordinator.config_entry), 2)
|
||||
if median is not None
|
||||
else None
|
||||
)
|
||||
return avg_result, median_result
|
||||
|
||||
# Single value result (min/max functions)
|
||||
|
|
@ -61,6 +65,6 @@ class TibberPricesWindow24hCalculator(TibberPricesBaseCalculator):
|
|||
if value is None:
|
||||
return None
|
||||
|
||||
# Always return in minor currency units (cents/øre) with 2 decimals
|
||||
result = get_price_value(value, in_euro=False)
|
||||
# Return in configured display currency units with 2 decimals
|
||||
result = get_price_value(value, config_entry=self.coordinator.config_entry)
|
||||
return round(result, 2)
|
||||
|
|
|
|||
|
|
@ -41,9 +41,9 @@ async def call_chartdata_service_for_metadata_async(
|
|||
# Force metadata to "only" - this sensor ONLY provides metadata
|
||||
service_params["metadata"] = "only"
|
||||
|
||||
# Default to minor_currency=True for ApexCharts compatibility (can be overridden in configuration.yaml)
|
||||
if "minor_currency" not in service_params:
|
||||
service_params["minor_currency"] = True
|
||||
# Default to subunit_currency=True for ApexCharts compatibility (can be overridden in configuration.yaml)
|
||||
if "subunit_currency" not in service_params:
|
||||
service_params["subunit_currency"] = True
|
||||
|
||||
# Call get_chartdata service using official HA service system
|
||||
try:
|
||||
|
|
|
|||
|
|
@ -10,14 +10,16 @@ from custom_components.tibber_prices.binary_sensor.attributes import (
|
|||
)
|
||||
from custom_components.tibber_prices.const import (
|
||||
CONF_AVERAGE_SENSOR_DISPLAY,
|
||||
CONF_CURRENCY_DISPLAY_MODE,
|
||||
CONF_PRICE_RATING_THRESHOLD_HIGH,
|
||||
CONF_PRICE_RATING_THRESHOLD_LOW,
|
||||
DEFAULT_AVERAGE_SENSOR_DISPLAY,
|
||||
DEFAULT_PRICE_RATING_THRESHOLD_HIGH,
|
||||
DEFAULT_PRICE_RATING_THRESHOLD_LOW,
|
||||
DISPLAY_MODE_BASE,
|
||||
DOMAIN,
|
||||
format_price_unit_major,
|
||||
format_price_unit_minor,
|
||||
get_display_unit_factor,
|
||||
get_display_unit_string,
|
||||
)
|
||||
from custom_components.tibber_prices.coordinator import (
|
||||
MINUTE_UPDATE_ENTITY_KEYS,
|
||||
|
|
@ -348,7 +350,7 @@ class TibberPricesSensor(TibberPricesEntity, RestoreSensor):
|
|||
|
||||
Returns:
|
||||
Aggregated value based on type:
|
||||
- "price": float (average price in minor currency units)
|
||||
- "price": float (average price in subunit currency units)
|
||||
- "level": str (aggregated level: "very_cheap", "cheap", etc.)
|
||||
- "rating": str (aggregated rating: "low", "normal", "high")
|
||||
|
||||
|
|
@ -417,7 +419,7 @@ class TibberPricesSensor(TibberPricesEntity, RestoreSensor):
|
|||
stat_func: Statistical function (min, max, or lambda for avg)
|
||||
|
||||
Returns:
|
||||
Price value in minor currency units (cents/øre), or None if unavailable
|
||||
Price value in subunit currency units (cents/øre), or None if unavailable
|
||||
|
||||
"""
|
||||
if not self.coordinator.data:
|
||||
|
|
@ -452,8 +454,8 @@ class TibberPricesSensor(TibberPricesEntity, RestoreSensor):
|
|||
self._last_extreme_interval = pi["interval"]
|
||||
break
|
||||
|
||||
# Always return in minor currency units (cents/øre) with 2 decimals
|
||||
result = get_price_value(value, in_euro=False)
|
||||
# Return in configured display currency units with 2 decimals
|
||||
result = get_price_value(value, config_entry=self.coordinator.config_entry)
|
||||
return round(result, 2)
|
||||
|
||||
def _get_daily_aggregated_value(
|
||||
|
|
@ -519,7 +521,7 @@ class TibberPricesSensor(TibberPricesEntity, RestoreSensor):
|
|||
stat_func: Function from average_utils (e.g., calculate_current_trailing_avg)
|
||||
|
||||
Returns:
|
||||
Price value in minor currency units (cents/øre), or None if unavailable
|
||||
Price value in subunit currency units (cents/øre), or None if unavailable
|
||||
|
||||
"""
|
||||
if not self.coordinator.data:
|
||||
|
|
@ -530,8 +532,8 @@ class TibberPricesSensor(TibberPricesEntity, RestoreSensor):
|
|||
if value is None:
|
||||
return None
|
||||
|
||||
# Always return in minor currency units (cents/øre) with 2 decimals
|
||||
result = get_price_value(value, in_euro=False)
|
||||
# Return in configured display currency units with 2 decimals
|
||||
result = get_price_value(value, config_entry=self.coordinator.config_entry)
|
||||
return round(result, 2)
|
||||
|
||||
def _translate_rating_level(self, level: str) -> str:
|
||||
|
|
@ -571,19 +573,22 @@ class TibberPricesSensor(TibberPricesEntity, RestoreSensor):
|
|||
hours: Number of hours to look ahead (1, 2, 3, 4, 5, 6, 8, 12)
|
||||
|
||||
Returns:
|
||||
Average price in minor currency units (e.g., cents), or None if unavailable
|
||||
Average price in subunit currency units (e.g., cents), or None if unavailable
|
||||
|
||||
"""
|
||||
avg_price, median_price = calculate_next_n_hours_avg(self.coordinator.data, hours, time=self.coordinator.time)
|
||||
if avg_price is None:
|
||||
return None
|
||||
|
||||
# Get display unit factor (100 for minor, 1 for major)
|
||||
factor = get_display_unit_factor(self.coordinator.config_entry)
|
||||
|
||||
# Store median for attributes
|
||||
if median_price is not None:
|
||||
self.cached_data[f"next_avg_{hours}h_median"] = round(median_price * 100, 2)
|
||||
self.cached_data[f"next_avg_{hours}h_median"] = round(median_price * factor, 2)
|
||||
|
||||
# Convert from major to minor currency units (e.g., EUR to cents)
|
||||
return round(avg_price * 100, 2)
|
||||
# Convert from major to display currency units
|
||||
return round(avg_price * factor, 2)
|
||||
|
||||
def _get_data_timestamp(self) -> datetime | None:
|
||||
"""
|
||||
|
|
@ -846,12 +851,8 @@ class TibberPricesSensor(TibberPricesEntity, RestoreSensor):
|
|||
if self.coordinator.data:
|
||||
currency = self.coordinator.data.get("currency")
|
||||
|
||||
# Use major currency unit for Energy Dashboard sensor
|
||||
if self.entity_description.key == "current_interval_price_major":
|
||||
return format_price_unit_major(currency)
|
||||
|
||||
# Use minor currency unit for all other price sensors
|
||||
return format_price_unit_minor(currency)
|
||||
# Get unit based on user configuration (major or minor)
|
||||
return get_display_unit_string(self.coordinator.config_entry, currency)
|
||||
|
||||
# For all other sensors, use unit from entity description
|
||||
return self.entity_description.native_unit_of_measurement
|
||||
|
|
@ -932,6 +933,43 @@ class TibberPricesSensor(TibberPricesEntity, RestoreSensor):
|
|||
# Fall back to static icon from entity description
|
||||
return icon or self.entity_description.icon
|
||||
|
||||
@property
|
||||
def suggested_display_precision(self) -> int | None:
|
||||
"""
|
||||
Return suggested display precision based on currency display mode.
|
||||
|
||||
For MONETARY sensors:
|
||||
- Current/Next Interval Price: Show exact price with higher precision
|
||||
- Base currency (€/kr): 4 decimals (e.g., 0.1234 €)
|
||||
- Subunit currency (ct/øre): 2 decimals (e.g., 12.34 ct)
|
||||
- All other price sensors:
|
||||
- Base currency (€/kr): 2 decimals (e.g., 0.12 €)
|
||||
- Subunit currency (ct/øre): 1 decimal (e.g., 12.5 ct)
|
||||
|
||||
For non-MONETARY sensors, use static value from entity description.
|
||||
"""
|
||||
# Only apply dynamic precision to MONETARY sensors
|
||||
if self.entity_description.device_class != SensorDeviceClass.MONETARY:
|
||||
return self.entity_description.suggested_display_precision
|
||||
|
||||
# Check display mode configuration
|
||||
display_mode = self.coordinator.config_entry.options.get(CONF_CURRENCY_DISPLAY_MODE, DISPLAY_MODE_BASE)
|
||||
|
||||
# Special case: Energy Dashboard sensor always shows base currency with 4 decimals
|
||||
# regardless of display mode (it's always in base currency by design)
|
||||
if self.entity_description.key == "current_interval_price_base":
|
||||
return 4
|
||||
|
||||
# Special case: Current and Next interval price sensors get higher precision
|
||||
# to show exact prices as received from API
|
||||
if self.entity_description.key in ("current_interval_price", "next_interval_price"):
|
||||
# Major: 4 decimals (0.1234 €), Minor: 2 decimals (12.34 ct)
|
||||
return 4 if display_mode == DISPLAY_MODE_BASE else 2
|
||||
|
||||
# All other sensors: Standard precision
|
||||
# Major: 2 decimals (0.12 €), Minor: 1 decimal (12.5 ct)
|
||||
return 2 if display_mode == DISPLAY_MODE_BASE else 1
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self) -> dict[str, Any] | None:
|
||||
"""Return additional state attributes."""
|
||||
|
|
|
|||
|
|
@ -68,13 +68,13 @@ INTERVAL_PRICE_SENSORS = (
|
|||
suggested_display_precision=2,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="current_interval_price_major",
|
||||
translation_key="current_interval_price_major",
|
||||
key="current_interval_price_base",
|
||||
translation_key="current_interval_price_base",
|
||||
name="Current Electricity Price (Energy Dashboard)",
|
||||
icon="mdi:cash", # Dynamic: shows cash-multiple/plus/cash/minus/remove based on price level
|
||||
device_class=SensorDeviceClass.MONETARY,
|
||||
state_class=SensorStateClass.TOTAL, # MONETARY requires TOTAL or None for Energy Dashboard
|
||||
suggested_display_precision=4, # More precision for major currency (e.g., 0.2534 EUR/kWh)
|
||||
suggested_display_precision=4, # More precision for base currency (e.g., 0.2534 EUR/kWh)
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="next_interval_price",
|
||||
|
|
@ -181,7 +181,7 @@ ROLLING_HOUR_PRICE_SENSORS = (
|
|||
icon="mdi:cash", # Dynamic: shows cash-multiple/plus/cash/minus/remove based on aggregated price level
|
||||
device_class=SensorDeviceClass.MONETARY,
|
||||
state_class=SensorStateClass.TOTAL, # MONETARY requires TOTAL or None
|
||||
suggested_display_precision=1,
|
||||
suggested_display_precision=2,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="next_hour_average_price",
|
||||
|
|
@ -190,7 +190,7 @@ ROLLING_HOUR_PRICE_SENSORS = (
|
|||
icon="mdi:cash-fast", # Dynamic: shows cash-multiple/plus/cash/minus/remove based on aggregated price level
|
||||
device_class=SensorDeviceClass.MONETARY,
|
||||
state_class=SensorStateClass.TOTAL, # MONETARY requires TOTAL or None
|
||||
suggested_display_precision=1,
|
||||
suggested_display_precision=2,
|
||||
),
|
||||
)
|
||||
|
||||
|
|
@ -259,7 +259,7 @@ DAILY_STAT_SENSORS = (
|
|||
icon="mdi:arrow-collapse-down",
|
||||
device_class=SensorDeviceClass.MONETARY,
|
||||
state_class=SensorStateClass.TOTAL, # MONETARY requires TOTAL or None
|
||||
suggested_display_precision=1,
|
||||
suggested_display_precision=2,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="highest_price_today",
|
||||
|
|
@ -268,7 +268,7 @@ DAILY_STAT_SENSORS = (
|
|||
icon="mdi:arrow-collapse-up",
|
||||
device_class=SensorDeviceClass.MONETARY,
|
||||
state_class=SensorStateClass.TOTAL, # MONETARY requires TOTAL or None
|
||||
suggested_display_precision=1,
|
||||
suggested_display_precision=2,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="average_price_today",
|
||||
|
|
@ -277,7 +277,7 @@ DAILY_STAT_SENSORS = (
|
|||
icon="mdi:chart-line",
|
||||
device_class=SensorDeviceClass.MONETARY,
|
||||
state_class=SensorStateClass.TOTAL, # MONETARY requires TOTAL or None
|
||||
suggested_display_precision=1,
|
||||
suggested_display_precision=2,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="lowest_price_tomorrow",
|
||||
|
|
@ -286,7 +286,7 @@ DAILY_STAT_SENSORS = (
|
|||
icon="mdi:arrow-collapse-down",
|
||||
device_class=SensorDeviceClass.MONETARY,
|
||||
state_class=SensorStateClass.TOTAL, # MONETARY requires TOTAL or None
|
||||
suggested_display_precision=1,
|
||||
suggested_display_precision=2,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="highest_price_tomorrow",
|
||||
|
|
@ -295,7 +295,7 @@ DAILY_STAT_SENSORS = (
|
|||
icon="mdi:arrow-collapse-up",
|
||||
device_class=SensorDeviceClass.MONETARY,
|
||||
state_class=SensorStateClass.TOTAL, # MONETARY requires TOTAL or None
|
||||
suggested_display_precision=1,
|
||||
suggested_display_precision=2,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="average_price_tomorrow",
|
||||
|
|
@ -304,7 +304,7 @@ DAILY_STAT_SENSORS = (
|
|||
icon="mdi:chart-line",
|
||||
device_class=SensorDeviceClass.MONETARY,
|
||||
state_class=SensorStateClass.TOTAL, # MONETARY requires TOTAL or None
|
||||
suggested_display_precision=1,
|
||||
suggested_display_precision=2,
|
||||
),
|
||||
)
|
||||
|
||||
|
|
@ -395,7 +395,7 @@ WINDOW_24H_SENSORS = (
|
|||
device_class=SensorDeviceClass.MONETARY,
|
||||
state_class=SensorStateClass.TOTAL, # MONETARY requires TOTAL or None
|
||||
entity_registry_enabled_default=False,
|
||||
suggested_display_precision=1,
|
||||
suggested_display_precision=2,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="leading_price_average",
|
||||
|
|
@ -405,7 +405,7 @@ WINDOW_24H_SENSORS = (
|
|||
device_class=SensorDeviceClass.MONETARY,
|
||||
state_class=SensorStateClass.TOTAL, # MONETARY requires TOTAL or None
|
||||
entity_registry_enabled_default=False, # Advanced use case
|
||||
suggested_display_precision=1,
|
||||
suggested_display_precision=2,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="trailing_price_min",
|
||||
|
|
@ -415,7 +415,7 @@ WINDOW_24H_SENSORS = (
|
|||
device_class=SensorDeviceClass.MONETARY,
|
||||
state_class=SensorStateClass.TOTAL, # MONETARY requires TOTAL or None
|
||||
entity_registry_enabled_default=False,
|
||||
suggested_display_precision=1,
|
||||
suggested_display_precision=2,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="trailing_price_max",
|
||||
|
|
@ -425,7 +425,7 @@ WINDOW_24H_SENSORS = (
|
|||
device_class=SensorDeviceClass.MONETARY,
|
||||
state_class=SensorStateClass.TOTAL, # MONETARY requires TOTAL or None
|
||||
entity_registry_enabled_default=False,
|
||||
suggested_display_precision=1,
|
||||
suggested_display_precision=2,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="leading_price_min",
|
||||
|
|
@ -435,7 +435,7 @@ WINDOW_24H_SENSORS = (
|
|||
device_class=SensorDeviceClass.MONETARY,
|
||||
state_class=SensorStateClass.TOTAL, # MONETARY requires TOTAL or None
|
||||
entity_registry_enabled_default=False, # Advanced use case
|
||||
suggested_display_precision=1,
|
||||
suggested_display_precision=2,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="leading_price_max",
|
||||
|
|
@ -445,7 +445,7 @@ WINDOW_24H_SENSORS = (
|
|||
device_class=SensorDeviceClass.MONETARY,
|
||||
state_class=SensorStateClass.TOTAL, # MONETARY requires TOTAL or None
|
||||
entity_registry_enabled_default=False, # Advanced use case
|
||||
suggested_display_precision=1,
|
||||
suggested_display_precision=2,
|
||||
),
|
||||
)
|
||||
|
||||
|
|
@ -463,7 +463,7 @@ FUTURE_AVG_SENSORS = (
|
|||
icon="mdi:chart-line",
|
||||
device_class=SensorDeviceClass.MONETARY,
|
||||
state_class=SensorStateClass.TOTAL, # MONETARY requires TOTAL or None
|
||||
suggested_display_precision=1,
|
||||
suggested_display_precision=2,
|
||||
entity_registry_enabled_default=True,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
|
|
@ -473,7 +473,7 @@ FUTURE_AVG_SENSORS = (
|
|||
icon="mdi:chart-line",
|
||||
device_class=SensorDeviceClass.MONETARY,
|
||||
state_class=SensorStateClass.TOTAL, # MONETARY requires TOTAL or None
|
||||
suggested_display_precision=1,
|
||||
suggested_display_precision=2,
|
||||
entity_registry_enabled_default=True,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
|
|
@ -483,7 +483,7 @@ FUTURE_AVG_SENSORS = (
|
|||
icon="mdi:chart-line",
|
||||
device_class=SensorDeviceClass.MONETARY,
|
||||
state_class=SensorStateClass.TOTAL, # MONETARY requires TOTAL or None
|
||||
suggested_display_precision=1,
|
||||
suggested_display_precision=2,
|
||||
entity_registry_enabled_default=True,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
|
|
@ -493,7 +493,7 @@ FUTURE_AVG_SENSORS = (
|
|||
icon="mdi:chart-line",
|
||||
device_class=SensorDeviceClass.MONETARY,
|
||||
state_class=SensorStateClass.TOTAL, # MONETARY requires TOTAL or None
|
||||
suggested_display_precision=1,
|
||||
suggested_display_precision=2,
|
||||
entity_registry_enabled_default=True,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
|
|
@ -503,7 +503,7 @@ FUTURE_AVG_SENSORS = (
|
|||
icon="mdi:chart-line",
|
||||
device_class=SensorDeviceClass.MONETARY,
|
||||
state_class=SensorStateClass.TOTAL, # MONETARY requires TOTAL or None
|
||||
suggested_display_precision=1,
|
||||
suggested_display_precision=2,
|
||||
entity_registry_enabled_default=True,
|
||||
),
|
||||
# Disabled by default: 6h, 8h, 12h (advanced use cases)
|
||||
|
|
@ -514,7 +514,7 @@ FUTURE_AVG_SENSORS = (
|
|||
icon="mdi:chart-line",
|
||||
device_class=SensorDeviceClass.MONETARY,
|
||||
state_class=SensorStateClass.TOTAL, # MONETARY requires TOTAL or None
|
||||
suggested_display_precision=1,
|
||||
suggested_display_precision=2,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
|
|
@ -524,7 +524,7 @@ FUTURE_AVG_SENSORS = (
|
|||
icon="mdi:chart-line",
|
||||
device_class=SensorDeviceClass.MONETARY,
|
||||
state_class=SensorStateClass.TOTAL, # MONETARY requires TOTAL or None
|
||||
suggested_display_precision=1,
|
||||
suggested_display_precision=2,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
|
|
@ -534,7 +534,7 @@ FUTURE_AVG_SENSORS = (
|
|||
icon="mdi:chart-line",
|
||||
device_class=SensorDeviceClass.MONETARY,
|
||||
state_class=SensorStateClass.TOTAL, # MONETARY requires TOTAL or None
|
||||
suggested_display_precision=1,
|
||||
suggested_display_precision=2,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -23,7 +23,9 @@ from typing import TYPE_CHECKING
|
|||
|
||||
if TYPE_CHECKING:
|
||||
from custom_components.tibber_prices.coordinator.time_service import TibberPricesTimeService
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
|
||||
from custom_components.tibber_prices.const import get_display_unit_factor
|
||||
from custom_components.tibber_prices.coordinator.helpers import get_intervals_for_day_offsets
|
||||
from custom_components.tibber_prices.entity_utils.helpers import get_price_value
|
||||
from custom_components.tibber_prices.utils.average import calculate_median
|
||||
|
|
@ -36,16 +38,20 @@ if TYPE_CHECKING:
|
|||
from collections.abc import Callable
|
||||
|
||||
|
||||
def aggregate_price_data(window_data: list[dict]) -> tuple[float | None, float | None]:
|
||||
def aggregate_price_data(
|
||||
window_data: list[dict],
|
||||
config_entry: ConfigEntry,
|
||||
) -> tuple[float | None, float | None]:
|
||||
"""
|
||||
Calculate average and median price from window data.
|
||||
|
||||
Args:
|
||||
window_data: List of price interval dictionaries with 'total' key
|
||||
window_data: List of price interval dictionaries with 'total' key.
|
||||
config_entry: Config entry to get display unit configuration.
|
||||
|
||||
Returns:
|
||||
Tuple of (average price, median price) in minor currency units (cents/øre),
|
||||
or (None, None) if no prices
|
||||
Tuple of (average price, median price) in display currency units,
|
||||
or (None, None) if no prices.
|
||||
|
||||
"""
|
||||
prices = [float(i["total"]) for i in window_data if "total" in i]
|
||||
|
|
@ -54,8 +60,9 @@ def aggregate_price_data(window_data: list[dict]) -> tuple[float | None, float |
|
|||
# Calculate both average and median
|
||||
avg = sum(prices) / len(prices)
|
||||
median = calculate_median(prices)
|
||||
# Return in minor currency units (cents/øre)
|
||||
return round(avg * 100, 2), round(median * 100, 2) if median is not None else None
|
||||
# Convert to display currency unit based on configuration
|
||||
factor = get_display_unit_factor(config_entry)
|
||||
return round(avg * factor, 2), round(median * factor, 2) if median is not None else None
|
||||
|
||||
|
||||
def aggregate_level_data(window_data: list[dict]) -> str | None:
|
||||
|
|
@ -106,25 +113,29 @@ def aggregate_window_data(
|
|||
value_type: str,
|
||||
threshold_low: float,
|
||||
threshold_high: float,
|
||||
config_entry: ConfigEntry,
|
||||
) -> str | float | None:
|
||||
"""
|
||||
Aggregate data from multiple intervals based on value type.
|
||||
|
||||
Unified helper that routes to appropriate aggregation function.
|
||||
|
||||
NOTE: This function is legacy code - rolling_hour calculator has its own implementation.
|
||||
|
||||
Args:
|
||||
window_data: List of price interval dictionaries
|
||||
value_type: Type of value to aggregate ('price', 'level', or 'rating')
|
||||
threshold_low: Low threshold for rating calculation
|
||||
threshold_high: High threshold for rating calculation
|
||||
window_data: List of price interval dictionaries.
|
||||
value_type: Type of value to aggregate ('price', 'level', or 'rating').
|
||||
threshold_low: Low threshold for rating calculation.
|
||||
threshold_high: High threshold for rating calculation.
|
||||
config_entry: Config entry to get display unit configuration.
|
||||
|
||||
Returns:
|
||||
Aggregated value (price as float, level/rating as str), or None if no data
|
||||
Aggregated value (price as float, level/rating as str), or None if no data.
|
||||
|
||||
"""
|
||||
# Map value types to aggregation functions
|
||||
aggregators: dict[str, Callable] = {
|
||||
"price": lambda data: aggregate_price_data(data)[0], # Use only average from tuple
|
||||
"price": lambda data: aggregate_price_data(data, config_entry)[0], # Use only average from tuple
|
||||
"level": lambda data: aggregate_level_data(data),
|
||||
"rating": lambda data: aggregate_rating_data(data, threshold_low, threshold_high),
|
||||
}
|
||||
|
|
@ -151,7 +162,7 @@ def get_hourly_price_value(
|
|||
Args:
|
||||
coordinator_data: Coordinator data dict
|
||||
hour_offset: Hour offset from current time (positive=future, negative=past)
|
||||
in_euro: If True, return price in major currency (EUR), else minor (cents/øre)
|
||||
in_euro: If True, return price in base currency (EUR), else minor (cents/øre)
|
||||
time: TibberPricesTimeService instance (required)
|
||||
|
||||
Returns:
|
||||
|
|
|
|||
|
|
@ -85,7 +85,7 @@ def get_value_getter_mapping( # noqa: PLR0913 - needs all calculators as parame
|
|||
"current_interval_price": lambda: interval_calculator.get_interval_value(
|
||||
interval_offset=0, value_type="price", in_euro=False
|
||||
),
|
||||
"current_interval_price_major": lambda: interval_calculator.get_interval_value(
|
||||
"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(
|
||||
|
|
|
|||
|
|
@ -54,7 +54,7 @@ def aggregate_hourly_exact( # noqa: PLR0913, PLR0912, PLR0915
|
|||
price_field: str,
|
||||
*,
|
||||
coordinator: Any,
|
||||
use_minor_currency: bool = False,
|
||||
use_subunit_currency: bool = False,
|
||||
round_decimals: int | None = None,
|
||||
include_level: bool = False,
|
||||
include_rating_level: bool = False,
|
||||
|
|
@ -80,7 +80,7 @@ def aggregate_hourly_exact( # noqa: PLR0913, PLR0912, PLR0915
|
|||
start_time_field: Custom name for start time field
|
||||
price_field: Custom name for price field
|
||||
coordinator: Data update coordinator instance (required)
|
||||
use_minor_currency: Convert to minor currency units (cents/øre)
|
||||
use_subunit_currency: Convert to subunit currency units (cents/øre)
|
||||
round_decimals: Optional decimal rounding
|
||||
include_level: Include aggregated level field
|
||||
include_rating_level: Include aggregated rating_level field
|
||||
|
|
@ -160,8 +160,8 @@ def aggregate_hourly_exact( # noqa: PLR0913, PLR0912, PLR0915
|
|||
if hour_intervals:
|
||||
avg_price = sum(hour_intervals) / len(hour_intervals)
|
||||
|
||||
# Convert to minor currency (cents/øre) if requested
|
||||
avg_price = round(avg_price * 100, 2) if use_minor_currency else round(avg_price, 4)
|
||||
# Convert to subunit currency (cents/øre) if requested
|
||||
avg_price = round(avg_price * 100, 2) if use_subunit_currency else round(avg_price, 4)
|
||||
|
||||
# Apply custom rounding if specified
|
||||
if round_decimals is not None:
|
||||
|
|
@ -204,7 +204,7 @@ def get_period_data( # noqa: PLR0913, PLR0912, PLR0915, C901
|
|||
period_filter: str,
|
||||
days: list[str],
|
||||
output_format: str,
|
||||
minor_currency: bool,
|
||||
subunit_currency: bool,
|
||||
round_decimals: int | None,
|
||||
level_filter: list[str] | None,
|
||||
rating_level_filter: list[str] | None,
|
||||
|
|
@ -225,15 +225,15 @@ def get_period_data( # noqa: PLR0913, PLR0912, PLR0915, C901
|
|||
When period_filter is specified, returns the precomputed period summaries
|
||||
from the coordinator instead of filtering intervals.
|
||||
|
||||
Note: Period prices (price_median) are stored in minor currency units (ct/øre).
|
||||
They are converted to major currency unless minor_currency=True.
|
||||
Note: Period prices (price_median) are stored in base currency units (€/kr/$/£).
|
||||
They are converted to subunit currency units (ct/øre/¢/p) if subunit_currency=True.
|
||||
|
||||
Args:
|
||||
coordinator: Data coordinator with period summaries
|
||||
period_filter: "best_price" or "peak_price"
|
||||
days: List of days to include
|
||||
output_format: "array_of_objects" or "array_of_arrays"
|
||||
minor_currency: If False, convert prices from minor to major units
|
||||
subunit_currency: If False, convert prices from minor to major units
|
||||
round_decimals: Optional decimal rounding
|
||||
level_filter: Optional level filter
|
||||
rating_level_filter: Optional rating level filter
|
||||
|
|
@ -347,9 +347,9 @@ def get_period_data( # noqa: PLR0913, PLR0912, PLR0915, C901
|
|||
# Median is more representative than mean for periods with gap tolerance
|
||||
# (single "normal" intervals between cheap/expensive ones don't skew the display)
|
||||
price_median = period.get("price_median", 0.0)
|
||||
# Convert to major currency unless minor_currency=True
|
||||
if not minor_currency:
|
||||
price_median = price_median / 100
|
||||
# Convert to subunit currency if subunit_currency=True (periods stored in major)
|
||||
if subunit_currency:
|
||||
price_median = price_median * 100
|
||||
if round_decimals is not None:
|
||||
price_median = round(price_median, round_decimals)
|
||||
data_point[price_field] = price_median
|
||||
|
|
@ -373,9 +373,9 @@ def get_period_data( # noqa: PLR0913, PLR0912, PLR0915, C901
|
|||
# 3. End time with NULL (cleanly terminate segment for ApexCharts)
|
||||
# Use price_median for consistency with sensor states (more representative for periods)
|
||||
price_median = period.get("price_median", 0.0)
|
||||
# Convert to major currency unless minor_currency=True
|
||||
if not minor_currency:
|
||||
price_median = price_median / 100
|
||||
# Convert to subunit currency if subunit_currency=True (periods stored in major)
|
||||
if subunit_currency:
|
||||
price_median = price_median * 100
|
||||
if round_decimals is not None:
|
||||
price_median = round(price_median, round_decimals)
|
||||
start = period["start"]
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ from custom_components.tibber_prices.const import (
|
|||
PRICE_RATING_HIGH,
|
||||
PRICE_RATING_LOW,
|
||||
PRICE_RATING_NORMAL,
|
||||
format_price_unit_minor,
|
||||
format_price_unit_subunit,
|
||||
get_translation,
|
||||
)
|
||||
from homeassistant.exceptions import ServiceValidationError
|
||||
|
|
@ -302,7 +302,7 @@ async def handle_apexcharts_yaml(call: ServiceCall) -> dict[str, Any]: # noqa:
|
|||
_, coordinator, _ = get_entry_and_data(hass, entry_id)
|
||||
# Get currency from coordinator data
|
||||
currency = coordinator.data.get("currency", "EUR")
|
||||
price_unit = format_price_unit_minor(currency)
|
||||
price_unit = format_price_unit_subunit(currency)
|
||||
|
||||
# Get entity registry for mapping
|
||||
entity_registry = async_get_entity_registry(hass)
|
||||
|
|
@ -354,7 +354,7 @@ async def handle_apexcharts_yaml(call: ServiceCall) -> dict[str, Any]: # noqa:
|
|||
f"service: 'get_chartdata', "
|
||||
f"return_response: true, "
|
||||
f"service_data: {{ entry_id: '{entry_id}', {day_param}{filter_param}, "
|
||||
f"output_format: 'array_of_arrays', insert_nulls: 'segments', minor_currency: true, "
|
||||
f"output_format: 'array_of_arrays', insert_nulls: 'segments', subunit_currency: true, "
|
||||
f"connect_segments: true }} }}); "
|
||||
f"return response.response.data;"
|
||||
)
|
||||
|
|
@ -367,7 +367,7 @@ async def handle_apexcharts_yaml(call: ServiceCall) -> dict[str, Any]: # noqa:
|
|||
f"service: 'get_chartdata', "
|
||||
f"return_response: true, "
|
||||
f"service_data: {{ entry_id: '{entry_id}', {day_param}{filter_param}, "
|
||||
f"output_format: 'array_of_arrays', insert_nulls: 'segments', minor_currency: true, "
|
||||
f"output_format: 'array_of_arrays', insert_nulls: 'segments', subunit_currency: true, "
|
||||
f"connect_segments: true }} }}); "
|
||||
f"return response.response.data;"
|
||||
)
|
||||
|
|
@ -417,7 +417,7 @@ async def handle_apexcharts_yaml(call: ServiceCall) -> dict[str, Any]: # noqa:
|
|||
f"return_response: true, "
|
||||
f"service_data: {{ entry_id: '{entry_id}', {day_param}"
|
||||
f"period_filter: 'best_price', "
|
||||
f"output_format: 'array_of_arrays', insert_nulls: 'segments', minor_currency: true }} }}); "
|
||||
f"output_format: 'array_of_arrays', insert_nulls: 'segments', subunit_currency: true }} }}); "
|
||||
f"const originalData = response.response.data; "
|
||||
f"return originalData.map((point, i) => {{ "
|
||||
f"const result = [point[0], point[1] === null ? null : 1]; "
|
||||
|
|
|
|||
|
|
@ -42,8 +42,8 @@ from custom_components.tibber_prices.const import (
|
|||
PRICE_RATING_HIGH,
|
||||
PRICE_RATING_LOW,
|
||||
PRICE_RATING_NORMAL,
|
||||
format_price_unit_major,
|
||||
format_price_unit_minor,
|
||||
format_price_unit_base,
|
||||
format_price_unit_subunit,
|
||||
get_currency_info,
|
||||
)
|
||||
from custom_components.tibber_prices.coordinator.helpers import (
|
||||
|
|
@ -65,7 +65,7 @@ def _calculate_metadata( # noqa: PLR0912, PLR0913, PLR0915
|
|||
currency: str,
|
||||
*,
|
||||
resolution: str,
|
||||
minor_currency: bool = False,
|
||||
subunit_currency: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Calculate metadata for chart visualization.
|
||||
|
|
@ -76,28 +76,28 @@ def _calculate_metadata( # noqa: PLR0912, PLR0913, PLR0915
|
|||
start_time_field: Name of the start time field
|
||||
currency: Currency code (e.g., "EUR", "NOK")
|
||||
resolution: Resolution type ("interval" or "hourly")
|
||||
minor_currency: Whether prices are in minor currency units
|
||||
subunit_currency: Whether prices are in subunit currency units
|
||||
|
||||
Returns:
|
||||
Metadata dictionary with price statistics, yaxis suggestions, and time info
|
||||
|
||||
"""
|
||||
# Get currency info (returns tuple: major_symbol, minor_symbol, minor_name)
|
||||
major_symbol, minor_symbol, minor_name = get_currency_info(currency)
|
||||
# Get currency info (returns tuple: base_symbol, subunit_symbol, subunit_name)
|
||||
base_symbol, subunit_symbol, subunit_name = get_currency_info(currency)
|
||||
|
||||
# Build currency object with only the active unit
|
||||
if minor_currency:
|
||||
if subunit_currency:
|
||||
currency_obj = {
|
||||
"code": currency,
|
||||
"symbol": minor_symbol,
|
||||
"name": minor_name, # Already capitalized in CURRENCY_INFO
|
||||
"unit": format_price_unit_minor(currency),
|
||||
"symbol": subunit_symbol,
|
||||
"name": subunit_name, # Already capitalized in CURRENCY_INFO
|
||||
"unit": format_price_unit_subunit(currency),
|
||||
}
|
||||
else:
|
||||
currency_obj = {
|
||||
"code": currency,
|
||||
"symbol": major_symbol,
|
||||
"unit": format_price_unit_major(currency),
|
||||
"symbol": base_symbol,
|
||||
"unit": format_price_unit_base(currency),
|
||||
}
|
||||
|
||||
# Extract all prices (excluding None values)
|
||||
|
|
@ -152,8 +152,8 @@ def _calculate_metadata( # noqa: PLR0912, PLR0913, PLR0915
|
|||
avg_position = (avg_val - min_val) / price_range if price_range > 0 else 0.5
|
||||
median_position = (median_val - min_val) / price_range if price_range > 0 else 0.5
|
||||
|
||||
# Position precision: 2 decimals for minor currency, 4 for major currency
|
||||
position_decimals = 2 if minor_currency else 4
|
||||
# Position precision: 2 decimals for subunit currency, 4 for base currency
|
||||
position_decimals = 2 if subunit_currency else 4
|
||||
|
||||
return {
|
||||
"min": round(min_val, 2),
|
||||
|
|
@ -192,13 +192,13 @@ def _calculate_metadata( # noqa: PLR0912, PLR0913, PLR0915
|
|||
interval_duration_minutes = 15 if resolution == "interval" else 60
|
||||
|
||||
# Calculate suggested yaxis bounds
|
||||
# For minor currency (ct, øre): integer values (floor/ceil)
|
||||
# For major currency (€, kr): 2 decimal places precision
|
||||
if minor_currency:
|
||||
# For subunit currency (ct, øre): integer values (floor/ceil)
|
||||
# For base currency (€, kr): 2 decimal places precision
|
||||
if subunit_currency:
|
||||
yaxis_min = math.floor(combined_stats["min"]) - 1 if combined_stats else 0
|
||||
yaxis_max = math.ceil(combined_stats["max"]) + 1 if combined_stats else 100
|
||||
else:
|
||||
# Major currency: round to 2 decimal places with padding
|
||||
# Base currency: round to 2 decimal places with padding
|
||||
yaxis_min = round(math.floor(combined_stats["min"] * 100) / 100 - 0.01, 2) if combined_stats else 0
|
||||
yaxis_max = round(math.ceil(combined_stats["max"] * 100) / 100 + 0.01, 2) if combined_stats else 1.0
|
||||
|
||||
|
|
@ -225,7 +225,7 @@ CHARTDATA_SERVICE_SCHEMA: Final = vol.Schema(
|
|||
vol.Optional("resolution", default="interval"): vol.In(["interval", "hourly"]),
|
||||
vol.Optional("output_format", default="array_of_objects"): vol.In(["array_of_objects", "array_of_arrays"]),
|
||||
vol.Optional("array_fields"): str,
|
||||
vol.Optional("minor_currency", default=False): bool,
|
||||
vol.Optional("subunit_currency", default=False): bool,
|
||||
vol.Optional("round_decimals"): vol.All(vol.Coerce(int), vol.Range(min=0, max=10)),
|
||||
vol.Optional("include_level", default=False): bool,
|
||||
vol.Optional("include_rating_level", default=False): bool,
|
||||
|
|
@ -321,7 +321,7 @@ async def handle_chartdata(call: ServiceCall) -> dict[str, Any]: # noqa: PLR091
|
|||
data_key = call.data.get("data_key", "data")
|
||||
resolution = call.data.get("resolution", "interval")
|
||||
output_format = call.data.get("output_format", "array_of_objects")
|
||||
minor_currency = call.data.get("minor_currency", False)
|
||||
subunit_currency = call.data.get("subunit_currency", False)
|
||||
metadata = call.data.get("metadata", "include")
|
||||
round_decimals = call.data.get("round_decimals")
|
||||
include_level = call.data.get("include_level", False)
|
||||
|
|
@ -351,7 +351,7 @@ async def handle_chartdata(call: ServiceCall) -> dict[str, Any]: # noqa: PLR091
|
|||
price = interval.get("total")
|
||||
if start_time is not None and price is not None:
|
||||
# Convert price to requested currency
|
||||
converted_price = round(price * 100, 2) if minor_currency else round(price, 4)
|
||||
converted_price = round(price * 100, 2) if subunit_currency else round(price, 4)
|
||||
chart_data_for_meta.append(
|
||||
{
|
||||
start_time_field: start_time.isoformat() if hasattr(start_time, "isoformat") else start_time,
|
||||
|
|
@ -366,7 +366,7 @@ async def handle_chartdata(call: ServiceCall) -> dict[str, Any]: # noqa: PLR091
|
|||
start_time_field=start_time_field,
|
||||
currency=coordinator.data.get("currency", "EUR"),
|
||||
resolution=resolution,
|
||||
minor_currency=minor_currency,
|
||||
subunit_currency=subunit_currency,
|
||||
)
|
||||
|
||||
return {"metadata": metadata}
|
||||
|
|
@ -400,7 +400,7 @@ async def handle_chartdata(call: ServiceCall) -> dict[str, Any]: # noqa: PLR091
|
|||
period_filter=period_filter,
|
||||
days=days,
|
||||
output_format=output_format,
|
||||
minor_currency=minor_currency,
|
||||
subunit_currency=subunit_currency,
|
||||
round_decimals=round_decimals,
|
||||
level_filter=level_filter,
|
||||
rating_level_filter=rating_level_filter,
|
||||
|
|
@ -467,7 +467,7 @@ async def handle_chartdata(call: ServiceCall) -> dict[str, Any]: # noqa: PLR091
|
|||
if prices:
|
||||
avg = sum(prices) / len(prices)
|
||||
# Apply same transformations as to regular prices
|
||||
avg = round(avg * 100, 2) if minor_currency else round(avg, 4)
|
||||
avg = round(avg * 100, 2) if subunit_currency else round(avg, 4)
|
||||
if round_decimals is not None:
|
||||
avg = round(avg, round_decimals)
|
||||
day_averages[day] = avg
|
||||
|
|
@ -510,8 +510,8 @@ async def handle_chartdata(call: ServiceCall) -> dict[str, Any]: # noqa: PLR091
|
|||
if not matches_filter:
|
||||
price = None
|
||||
elif price is not None:
|
||||
# Convert to minor currency (cents/øre) if requested
|
||||
price = round(price * 100, 2) if minor_currency else round(price, 4)
|
||||
# Convert to subunit currency (cents/øre) if requested
|
||||
price = round(price * 100, 2) if subunit_currency else round(price, 4)
|
||||
# Apply custom rounding if specified
|
||||
if round_decimals is not None:
|
||||
price = round(price, round_decimals)
|
||||
|
|
@ -558,7 +558,7 @@ async def handle_chartdata(call: ServiceCall) -> dict[str, Any]: # noqa: PLR091
|
|||
# Check if current interval matches filter
|
||||
if interval_value in filter_values: # type: ignore[operator]
|
||||
# Convert price
|
||||
converted_price = round(price * 100, 2) if minor_currency else round(price, 4)
|
||||
converted_price = round(price * 100, 2) if subunit_currency else round(price, 4)
|
||||
if round_decimals is not None:
|
||||
converted_price = round(converted_price, round_decimals)
|
||||
|
||||
|
|
@ -593,7 +593,7 @@ async def handle_chartdata(call: ServiceCall) -> dict[str, Any]: # noqa: PLR091
|
|||
# NULL point: stops series so it doesn't continue into next segment
|
||||
|
||||
converted_next_price = (
|
||||
round(next_price * 100, 2) if minor_currency else round(next_price, 4)
|
||||
round(next_price * 100, 2) if subunit_currency else round(next_price, 4)
|
||||
)
|
||||
if round_decimals is not None:
|
||||
converted_next_price = round(converted_next_price, round_decimals)
|
||||
|
|
@ -681,7 +681,7 @@ async def handle_chartdata(call: ServiceCall) -> dict[str, Any]: # noqa: PLR091
|
|||
|
||||
# Convert price
|
||||
converted_price = (
|
||||
round(midnight_price * 100, 2) if minor_currency else round(midnight_price, 4)
|
||||
round(midnight_price * 100, 2) if subunit_currency else round(midnight_price, 4)
|
||||
)
|
||||
if round_decimals is not None:
|
||||
converted_price = round(converted_price, round_decimals)
|
||||
|
|
@ -723,8 +723,8 @@ async def handle_chartdata(call: ServiceCall) -> dict[str, Any]: # noqa: PLR091
|
|||
):
|
||||
continue
|
||||
|
||||
# Convert to minor currency (cents/øre) if requested
|
||||
price = round(price * 100, 2) if minor_currency else round(price, 4)
|
||||
# Convert to subunit currency (cents/øre) if requested
|
||||
price = round(price * 100, 2) if subunit_currency else round(price, 4)
|
||||
|
||||
# Apply custom rounding if specified
|
||||
if round_decimals is not None:
|
||||
|
|
@ -759,7 +759,7 @@ async def handle_chartdata(call: ServiceCall) -> dict[str, Any]: # noqa: PLR091
|
|||
start_time_field,
|
||||
price_field,
|
||||
coordinator=coordinator,
|
||||
use_minor_currency=minor_currency,
|
||||
use_subunit_currency=subunit_currency,
|
||||
round_decimals=round_decimals,
|
||||
include_level=include_level,
|
||||
include_rating_level=include_rating_level,
|
||||
|
|
@ -828,7 +828,7 @@ async def handle_chartdata(call: ServiceCall) -> dict[str, Any]: # noqa: PLR091
|
|||
start_time_field=start_time_field,
|
||||
currency=coordinator.data.get("currency", "EUR"),
|
||||
resolution=resolution,
|
||||
minor_currency=minor_currency,
|
||||
subunit_currency=subunit_currency,
|
||||
)
|
||||
if metadata_obj:
|
||||
result["metadata"] = metadata_obj # type: ignore[index]
|
||||
|
|
@ -843,7 +843,7 @@ async def handle_chartdata(call: ServiceCall) -> dict[str, Any]: # noqa: PLR091
|
|||
start_time_field=start_time_field,
|
||||
currency=coordinator.data.get("currency", "EUR"),
|
||||
resolution=resolution,
|
||||
minor_currency=minor_currency,
|
||||
subunit_currency=subunit_currency,
|
||||
)
|
||||
if metadata_obj:
|
||||
result["metadata"] = metadata_obj # type: ignore[index]
|
||||
|
|
|
|||
|
|
@ -144,6 +144,17 @@
|
|||
},
|
||||
"submit": "Weiter →"
|
||||
},
|
||||
"display_settings": {
|
||||
"title": "💱 Währungsanzeige-Einstellungen",
|
||||
"description": "_{step_progress}_\n\n**Konfiguriere, wie Strompreise angezeigt werden - in Basiswährung (€, kr) oder Unterwährungseinheit (ct, øre).**\n\n---",
|
||||
"data": {
|
||||
"currency_display_mode": "Anzeigemodus"
|
||||
},
|
||||
"data_description": {
|
||||
"currency_display_mode": "Wähle, wie Preise angezeigt werden:\n\n• **Basiswährung** (€/kWh, kr/kWh): Dezimalwerte (z.B. 0,25 €/kWh) - Unterschiede sichtbar ab 3.-4. Nachkommastelle\n• **Unterwährungseinheit** (ct/kWh, øre/kWh): Größere Werte (z.B. 25,00 ct/kWh) - Unterschiede bereits ab 1. Nachkommastelle sichtbar\n\nStandard abhängig von deiner Währung:\n• EUR → Unterwährungseinheit (Cent) - deutsche/niederländische Präferenz\n• NOK/SEK/DKK → Basiswährung (Kronen) - skandinavische Präferenz\n• USD/GBP → Basiswährung\n\n**💡 Tipp:** Bei Auswahl von Unterwährungseinheit kannst du den zusätzlichen Sensor \"Aktueller Strompreis (Energie-Dashboard)\" aktivieren (standardmäßig deaktiviert)."
|
||||
},
|
||||
"submit": "Weiter →"
|
||||
},
|
||||
"current_interval_price_rating": {
|
||||
"title": "📊 Preisbewertungs-Schwellenwerte",
|
||||
"description": "_{step_progress}_\n\n**Konfiguriere Schwellenwerte für Preisbewertungsstufen (niedrig/normal/hoch) basierend auf dem Vergleich mit dem nachlaufenden 24-Stunden-Durchschnitt.**\n\n---",
|
||||
|
|
@ -338,7 +349,7 @@
|
|||
"current_interval_price": {
|
||||
"name": "Aktueller Strompreis"
|
||||
},
|
||||
"current_interval_price_major": {
|
||||
"current_interval_price_base": {
|
||||
"name": "Aktueller Strompreis (Energie-Dashboard)"
|
||||
},
|
||||
"next_interval_price": {
|
||||
|
|
@ -947,13 +958,13 @@
|
|||
"name": "Array-Felder",
|
||||
"description": "Definiere, welche Felder im array_of_arrays-Format enthalten sein sollen. Verwende Feldnamen in geschweiften Klammern, getrennt durch Kommas. Verfügbare Felder: start_time, price_per_kwh, level, rating_level, average. Felder werden automatisch aktiviert, auch wenn include_*-Optionen nicht gesetzt sind. Leer lassen für Standard (nur Zeitstempel und Preis)."
|
||||
},
|
||||
"minor_currency": {
|
||||
"name": "Kleinere Währungseinheit",
|
||||
"description": "Gibt Preise in kleineren Währungseinheiten zurück (Cent für EUR, Øre für NOK/SEK) statt in Hauptwährungseinheiten. Standardmäßig deaktiviert."
|
||||
"subunit_currency": {
|
||||
"name": "Unterwährungseinheit",
|
||||
"description": "Gibt Preise in Unterwährungseinheiten zurück (Cent für EUR, Øre für NOK/SEK) statt in Basiswährungseinheiten. Standardmäßig deaktiviert."
|
||||
},
|
||||
"round_decimals": {
|
||||
"name": "Dezimalstellen runden",
|
||||
"description": "Anzahl der Dezimalstellen, auf die Preise gerundet werden sollen (0-10). Falls nicht angegeben, wird die Standardgenauigkeit verwendet (4 Dezimalstellen für Hauptwährung, 2 für kleinere Währungseinheit)."
|
||||
"description": "Anzahl der Dezimalstellen, auf die Preise gerundet werden sollen (0-10). Falls nicht angegeben, wird die Standardgenauigkeit verwendet (4 Dezimalstellen für Basiswährung, 2 für Unterwährungseinheit)."
|
||||
},
|
||||
"include_level": {
|
||||
"name": "Preisniveau einschließen",
|
||||
|
|
@ -1123,6 +1134,12 @@
|
|||
"very_expensive": "Sehr teuer"
|
||||
}
|
||||
},
|
||||
"currency_display_mode": {
|
||||
"options": {
|
||||
"base": "Basiswährung (€, kr)",
|
||||
"subunit": "Unterwährungseinheit (ct, øre)"
|
||||
}
|
||||
},
|
||||
"average_sensor_display": {
|
||||
"options": {
|
||||
"median": "Median",
|
||||
|
|
|
|||
|
|
@ -144,6 +144,17 @@
|
|||
},
|
||||
"submit": "Continue →"
|
||||
},
|
||||
"display_settings": {
|
||||
"title": "💱 Currency Display Settings",
|
||||
"description": "_{step_progress}_\n\n**Configure how electricity prices are displayed - in base currency (€, kr) or subunit (ct, øre).**\n\n---",
|
||||
"data": {
|
||||
"currency_display_mode": "Display Mode"
|
||||
},
|
||||
"data_description": {
|
||||
"currency_display_mode": "Choose how prices are displayed:\n\n• **Base Currency** (€/kWh, kr/kWh): Decimal values (e.g., 0.25 €/kWh) - differences visible from 3rd-4th decimal place\n• **Subunit Currency** (ct/kWh, øre/kWh): Larger values (e.g., 25.00 ct/kWh) - differences visible from 1st decimal place\n\nDefault depends on your currency:\n• EUR → Subunit (cents) - German/Dutch preference\n• NOK/SEK/DKK → Base (kroner) - Scandinavian preference\n• USD/GBP → Base currency\n\n**💡 Tip:** When selecting Subunit Currency, you can enable the additional \"Current Electricity Price (Energy Dashboard)\" sensor (disabled by default)."
|
||||
},
|
||||
"submit": "Continue →"
|
||||
},
|
||||
"current_interval_price_rating": {
|
||||
"title": "📊 Price Rating Thresholds",
|
||||
"description": "_{step_progress}_\n\n**Configure thresholds for price rating levels (low/normal/high) based on comparison with trailing 24-hour average.**\n\n---",
|
||||
|
|
@ -336,7 +347,7 @@
|
|||
"current_interval_price": {
|
||||
"name": "Current Electricity Price"
|
||||
},
|
||||
"current_interval_price_major": {
|
||||
"current_interval_price_base": {
|
||||
"name": "Current Electricity Price (Energy Dashboard)"
|
||||
},
|
||||
"next_interval_price": {
|
||||
|
|
@ -945,13 +956,13 @@
|
|||
"name": "Array Fields",
|
||||
"description": "Define which fields to include. Use field names in curly braces, separated by commas. Available fields: start_time, price_per_kwh, level, rating_level, average. Fields will be automatically enabled even if include_* options are not set. Leave empty for default (timestamp and price only)."
|
||||
},
|
||||
"minor_currency": {
|
||||
"name": "Minor Currency",
|
||||
"description": "Return prices in minor currency units (cents for EUR, øre for NOK/SEK) instead of major currency units. Disabled by default."
|
||||
"subunit_currency": {
|
||||
"name": "Subunit Currency",
|
||||
"description": "Return prices in subunit currency units (cents for EUR, øre for NOK/SEK) instead of base currency units. Disabled by default."
|
||||
},
|
||||
"round_decimals": {
|
||||
"name": "Round Decimals",
|
||||
"description": "Number of decimal places to round prices to (0-10). If not specified, uses default precision (4 decimals for major currency, 2 for minor currency)."
|
||||
"description": "Number of decimal places to round prices to (0-10). If not specified, uses default precision (4 decimals for base currency, 2 for subunit currency)."
|
||||
},
|
||||
"data_key": {
|
||||
"name": "Data Key",
|
||||
|
|
@ -1121,6 +1132,12 @@
|
|||
"very_expensive": "Very expensive"
|
||||
}
|
||||
},
|
||||
"currency_display_mode": {
|
||||
"options": {
|
||||
"base": "Base Currency (€, kr)",
|
||||
"subunit": "Subunit Currency (ct, øre)"
|
||||
}
|
||||
},
|
||||
"average_sensor_display": {
|
||||
"options": {
|
||||
"median": "Median",
|
||||
|
|
|
|||
|
|
@ -144,6 +144,17 @@
|
|||
},
|
||||
"submit": "Videre til trinn 2"
|
||||
},
|
||||
"display_settings": {
|
||||
"title": "💱 Valutavisningsinnstillinger",
|
||||
"description": "_{step_progress}_\n\n**Konfigurer hvordan strømpriser vises - i basisvaluta (€, kr) eller underenhet (ct, øre).**\n\n---",
|
||||
"data": {
|
||||
"currency_display_mode": "Visningsmodus"
|
||||
},
|
||||
"data_description": {
|
||||
"currency_display_mode": "Velg hvordan priser vises:\n\n• **Basisvaluta** (€/kWh, kr/kWh): Desimalverdier (f.eks. 0,25 €/kWh) - forskjeller synlige fra 3.-4. desimalplass\n• **Underenhet** (ct/kWh, øre/kWh): Større verdier (f.eks. 25,00 ct/kWh) - forskjeller allerede synlige fra 1. desimalplass\n\nStandard avhenger av valutaen din:\n• EUR → Underenhet (cent) - tysk/nederlandsk preferanse\n• NOK/SEK/DKK → Basisvaluta (kroner) - skandinavisk preferanse\n• USD/GBP → Basisvaluta\n\n**💡 Tips:** Ved valg av underenhet kan du aktivere den ekstra sensoren \"Nåværende strømpris (Energi-dashboard)\" (deaktivert som standard)."
|
||||
},
|
||||
"submit": "Videre til trinn 3"
|
||||
},
|
||||
"current_interval_price_rating": {
|
||||
"title": "📊 Prisvurderings-terskler",
|
||||
"description": "_{step_progress}_\n\n**Konfigurer terskler for prisvurderingsnivåer (lav/normal/høy) basert på sammenligning med etterfølgende 24-timers gjennomsnitt.**\n\n---",
|
||||
|
|
@ -336,7 +347,7 @@
|
|||
"current_interval_price": {
|
||||
"name": "Nåværende strømpris"
|
||||
},
|
||||
"current_interval_price_major": {
|
||||
"current_interval_price_base": {
|
||||
"name": "Nåværende strømpris (Energi-dashboard)"
|
||||
},
|
||||
"next_interval_price": {
|
||||
|
|
@ -945,13 +956,13 @@
|
|||
"name": "Array-felt",
|
||||
"description": "Definer hvilke felt som skal inkluderes. Bruk feltnavn i krøllparenteser, adskilt med komma. Tilgjengelige felt: start_time, price_per_kwh, level, rating_level, average. Felt vil automatisk aktiveres selv om include_*-alternativene ikke er satt. La stå tom for standard (kun tidsstempel og pris)."
|
||||
},
|
||||
"minor_currency": {
|
||||
"name": "Mindre valutaenhet",
|
||||
"description": "Returner priser i mindre valutaenheter (øre for NOK/SEK, cent for EUR) i stedet for hovedvalutaenheter. Deaktivert som standard."
|
||||
"subunit_currency": {
|
||||
"name": "Underenhet valuta",
|
||||
"description": "Returner priser i underenhet valutaenheter (øre for NOK/SEK, cent for EUR) i stedet for basisvalutaenheter. Deaktivert som standard."
|
||||
},
|
||||
"round_decimals": {
|
||||
"name": "Rund desimaler",
|
||||
"description": "Antall desimalplasser å runde priser til (0-10). Hvis ikke angitt, brukes standard presisjon (4 desimaler for hovedvaluta, 2 for mindre valutaenhet)."
|
||||
"description": "Antall desimalplasser å runde priser til (0-10). Hvis ikke angitt, brukes standard presisjon (4 desimaler for basisvaluta, 2 for underenhet valuta)."
|
||||
},
|
||||
"include_level": {
|
||||
"name": "Inkluder prisnivå",
|
||||
|
|
@ -1121,6 +1132,12 @@
|
|||
"very_expensive": "Svært dyr"
|
||||
}
|
||||
},
|
||||
"currency_display_mode": {
|
||||
"options": {
|
||||
"base": "Basisvaluta (€, kr)",
|
||||
"subunit": "Underenhet valuta (ct, øre)"
|
||||
}
|
||||
},
|
||||
"average_sensor_display": {
|
||||
"options": {
|
||||
"median": "Median",
|
||||
|
|
|
|||
|
|
@ -144,6 +144,17 @@
|
|||
},
|
||||
"submit": "Doorgaan →"
|
||||
},
|
||||
"display_settings": {
|
||||
"title": "💱 Valuta-weergave-instellingen",
|
||||
"description": "_{step_progress}_\n\n**Configureer hoe elektriciteitsprijzen worden weergegeven - in basisvaluta (€, kr) of subeenheid (ct, øre).**\n\n---",
|
||||
"data": {
|
||||
"currency_display_mode": "Weergavemodus"
|
||||
},
|
||||
"data_description": {
|
||||
"currency_display_mode": "Kies hoe prijzen worden weergegeven:\n\n• **Basisvaluta** (€/kWh, kr/kWh): Decimaalwaarden (bijv. 0,25 €/kWh) - verschillen zichtbaar vanaf 3e-4e decimaal\n• **Subeenheid** (ct/kWh, øre/kWh): Grotere waarden (bijv. 25,00 ct/kWh) - verschillen al zichtbaar vanaf 1e decimaal\n\nStandaard hangt af van jouw valuta:\n• EUR → Subeenheid (cent) - Duitse/Nederlandse voorkeur\n• NOK/SEK/DKK → Basisvaluta (kronen) - Scandinavische voorkeur\n• USD/GBP → Basisvaluta\n\n**💡 Tip:** Bij keuze van subeenheid kun je de extra sensor \"Huidige elektriciteitsprijs (Energie-dashboard)\" activeren (standaard uitgeschakeld)."
|
||||
},
|
||||
"submit": "Doorgaan →"
|
||||
},
|
||||
"current_interval_price_rating": {
|
||||
"title": "📊 Prijsbeoordelingsdrempels",
|
||||
"description": "_{step_progress}_\n\n**Configureer drempels voor prijsbeoordelingsniveaus (laag/normaal/hoog) op basis van vergelijking met het lopende 24-uurs gemiddelde.**\n\n---",
|
||||
|
|
@ -336,7 +347,7 @@
|
|||
"current_interval_price": {
|
||||
"name": "Huidige elektriciteitsprijs"
|
||||
},
|
||||
"current_interval_price_major": {
|
||||
"current_interval_price_base": {
|
||||
"name": "Huidige elektriciteitsprijs (Energie-dashboard)"
|
||||
},
|
||||
"next_interval_price": {
|
||||
|
|
@ -945,13 +956,13 @@
|
|||
"name": "Array-velden",
|
||||
"description": "Definieer welke velden moeten worden opgenomen. Gebruik veldnamen tussen accolades, gescheiden door komma's. Beschikbare velden: start_time, price_per_kwh, level, rating_level, average. Velden worden automatisch ingeschakeld, zelfs als include_*-opties niet zijn ingesteld. Laat leeg voor standaard (alleen tijdstempel en prijs)."
|
||||
},
|
||||
"minor_currency": {
|
||||
"name": "Kleine valuta-eenheid",
|
||||
"description": "Retourneer prijzen in kleine valuta-eenheden (cent voor EUR, øre voor NOK/SEK) in plaats van grote valuta-eenheden. Standaard uitgeschakeld."
|
||||
"subunit_currency": {
|
||||
"name": "Subeenheid valuta",
|
||||
"description": "Retourneer prijzen in subeenheid valuta-eenheden (cent voor EUR, øre voor NOK/SEK) in plaats van basisvaluta-eenheden. Standaard uitgeschakeld."
|
||||
},
|
||||
"round_decimals": {
|
||||
"name": "Decimalen afronden",
|
||||
"description": "Aantal decimalen om prijzen op af te ronden (0-10). Indien niet opgegeven, wordt de standaardprecisie gebruikt (4 decimalen voor grote valuta, 2 voor kleine valuta-eenheid)."
|
||||
"description": "Aantal decimalen om prijzen op af te ronden (0-10). Indien niet opgegeven, wordt de standaardprecisie gebruikt (4 decimalen voor basisvaluta, 2 voor subeenheid valuta)."
|
||||
},
|
||||
"include_level": {
|
||||
"name": "Prijsniveau opnemen",
|
||||
|
|
@ -1121,6 +1132,12 @@
|
|||
"very_expensive": "Zeer duur"
|
||||
}
|
||||
},
|
||||
"currency_display_mode": {
|
||||
"options": {
|
||||
"base": "Basisvaluta (€, kr)",
|
||||
"subunit": "Subeenheid valuta (ct, øre)"
|
||||
}
|
||||
},
|
||||
"average_sensor_display": {
|
||||
"options": {
|
||||
"median": "Mediaan",
|
||||
|
|
|
|||
|
|
@ -142,6 +142,17 @@
|
|||
},
|
||||
"submit": "Fortsätt →"
|
||||
},
|
||||
"display_settings": {
|
||||
"title": "💱 Valutavisningsinställningar",
|
||||
"description": "_{step_progress}_\n\n**Konfigurera hur elpriser visas - i basvaluta (€, kr) eller underenhet (ct, öre).**\n\n---",
|
||||
"data": {
|
||||
"currency_display_mode": "Visningsläge"
|
||||
},
|
||||
"data_description": {
|
||||
"currency_display_mode": "Välj hur priser visas:\n\n• **Basvaluta** (€/kWh, kr/kWh): Decimalvärden (t.ex. 0,25 €/kWh) - skillnader synliga från 3:e-4:e decimalen\n• **Underenhet** (ct/kWh, öre/kWh): Större värden (t.ex. 25,00 ct/kWh) - skillnader redan synliga från 1:a decimalen\n\nStandard beror på din valuta:\n• EUR → Underenhet (cent) - tysk/nederländsk preferens\n• NOK/SEK/DKK → Basvaluta (kronor) - skandinavisk preferens\n• USD/GBP → Basvaluta\n\n**💡 Tips:** Vid val av underenhet kan du aktivera den extra sensorn \"Nuvarande elpris (Energipanel)\" (inaktiverad som standard)."
|
||||
},
|
||||
"submit": "Fortsätt →"
|
||||
},
|
||||
"current_interval_price_rating": {
|
||||
"title": "📊 Prisvärderingströsklar",
|
||||
"description": "_{step_progress}_\n\n**Konfigurera trösklar för prisvärderingsnivåer (låg/normal/hög) baserat på jämförelse med rullande 24-timmars genomsnitt.**\n\n---",
|
||||
|
|
@ -334,7 +345,7 @@
|
|||
"current_interval_price": {
|
||||
"name": "Nuvarande elpris"
|
||||
},
|
||||
"current_interval_price_major": {
|
||||
"current_interval_price_base": {
|
||||
"name": "Nuvarande elpris (Energipanel)"
|
||||
},
|
||||
"next_interval_price": {
|
||||
|
|
@ -943,13 +954,13 @@
|
|||
"name": "Array-fält",
|
||||
"description": "Definiera vilka fält som ska inkluderas. Använd fältnamn inom måsvingar, separerade med kommatecken. Tillgängliga fält: start_time, price_per_kwh, level, rating_level, average. Fält aktiveras automatiskt även om include_*-alternativ inte är inställda. Lämna tomt för standard (endast tidsstämpel och pris)."
|
||||
},
|
||||
"minor_currency": {
|
||||
"name": "Mindre valutaenhet",
|
||||
"description": "Returnera priser i mindre valutaenheter (öre för SEK/NOK, cent för EUR) istället för huvudvalutaenheter. Inaktiverad som standard."
|
||||
"subunit_currency": {
|
||||
"name": "Underenhet valuta",
|
||||
"description": "Returnera priser i underenhet valutaenheter (öre för SEK/NOK, cent för EUR) istället för basvalutaenheter. Inaktiverad som standard."
|
||||
},
|
||||
"round_decimals": {
|
||||
"name": "Avrunda decimaler",
|
||||
"description": "Antal decimaler att avrunda priser till (0-10). Om inte angivet används standardprecision (4 decimaler för huvudvaluta, 2 för mindre valutaenhet)."
|
||||
"description": "Antal decimaler att avrunda priser till (0-10). Om inte angivet används standardprecision (4 decimaler för basvaluta, 2 för underenhet valuta)."
|
||||
},
|
||||
"include_level": {
|
||||
"name": "Inkludera prisnivå",
|
||||
|
|
@ -1119,6 +1130,12 @@
|
|||
"very_expensive": "Mycket dyrt"
|
||||
}
|
||||
},
|
||||
"currency_display_mode": {
|
||||
"options": {
|
||||
"base": "Basvaluta (€, kr)",
|
||||
"subunit": "Underenhet valuta (ct, öre)"
|
||||
}
|
||||
},
|
||||
"average_sensor_display": {
|
||||
"options": {
|
||||
"median": "Median",
|
||||
|
|
|
|||
|
|
@ -59,7 +59,7 @@ def calculate_volatility_level(
|
|||
across different price levels and period lengths.
|
||||
|
||||
Args:
|
||||
prices: List of price values (in any unit, typically major currency units like EUR or NOK)
|
||||
prices: List of price values (in any unit, typically base currency units like EUR or NOK)
|
||||
threshold_moderate: Custom threshold for MODERATE level (default: use DEFAULT_VOLATILITY_THRESHOLD_MODERATE)
|
||||
threshold_high: Custom threshold for HIGH level (default: use DEFAULT_VOLATILITY_THRESHOLD_HIGH)
|
||||
threshold_very_high: Custom threshold for VERY_HIGH level (default: use DEFAULT_VOLATILITY_THRESHOLD_VERY_HIGH)
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ You can still call them from automations, scripts, and dashboards the same way a
|
|||
- **Period Support**: Return best/peak price period summaries instead of intervals
|
||||
- **Resolution Control**: Interval (15-minute) or hourly aggregation
|
||||
- **Customizable Field Names**: Rename output fields to match your chart library
|
||||
- **Currency Control**: Major (EUR/NOK) or minor (ct/øre) units
|
||||
- **Currency Control**: Override integration default - use base (€/kWh, kr/kWh) or subunit (ct/kWh, øre/kWh)
|
||||
|
||||
**Basic Example:**
|
||||
|
||||
|
|
@ -56,8 +56,8 @@ response_variable: chart_data
|
|||
| `day` | Days to include: yesterday, today, tomorrow | `["today", "tomorrow"]` |
|
||||
| `output_format` | `array_of_objects` or `array_of_arrays` | `array_of_objects` |
|
||||
| `resolution` | `interval` (15-min) or `hourly` | `interval` |
|
||||
| `minor_currency` | Return prices in ct/øre instead of EUR/NOK | `false` |
|
||||
| `round_decimals` | Decimal places (0-10) | 4 (major) or 2 (minor) |
|
||||
| `subunit_currency` | Override display mode: `true` for subunit (ct/øre), `false` for base (€/kr) | Integration setting |
|
||||
| `round_decimals` | Decimal places (0-10) | 2 (subunit) or 4 (base) |
|
||||
|
||||
**Rolling Window Mode:**
|
||||
|
||||
|
|
|
|||
|
|
@ -89,13 +89,18 @@ This means **all intervals meet your criteria** (very cheap day!):
|
|||
- Consider tightening filters (lower flex, higher min_distance)
|
||||
- Or add automation to only run during first detected period
|
||||
|
||||
### Prices are in wrong currency
|
||||
### Prices are in wrong currency or wrong units
|
||||
|
||||
Integration uses currency from your Tibber subscription:
|
||||
- EUR → displays in ct/kWh
|
||||
- NOK/SEK → displays in øre/kWh
|
||||
**Currency** is determined by your Tibber subscription (cannot be changed).
|
||||
|
||||
Cannot be changed (tied to your electricity contract).
|
||||
**Display mode** (base vs. subunit) is configurable:
|
||||
- Configure in: `Settings > Devices & Services > Tibber Prices > Configure`
|
||||
- Options:
|
||||
- **Base currency**: €/kWh, kr/kWh (decimal values like 0.25)
|
||||
- **Subunit**: ct/kWh, øre/kWh (larger values like 25.00)
|
||||
- Smart defaults: EUR → subunit, NOK/SEK/DKK → base currency
|
||||
|
||||
If you see unexpected units, check your configuration in the integration options.
|
||||
|
||||
### Tomorrow data not appearing at all
|
||||
|
||||
|
|
|
|||
|
|
@ -24,8 +24,8 @@ Quick reference for terms used throughout the documentation.
|
|||
|
||||
## C
|
||||
|
||||
**Currency Units**
|
||||
: Minor currency units used for display (ct for EUR, øre for NOK/SEK). Integration handles conversion automatically.
|
||||
**Currency Display Mode**
|
||||
: Configurable setting for how prices are shown. Choose base currency (€, kr) or subunit (ct, øre). Smart defaults apply: EUR → subunit, NOK/SEK/DKK → base.
|
||||
|
||||
**Coordinator**
|
||||
: Home Assistant component managing data fetching and updates. Polls Tibber API every 15 minutes.
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ This is an independent, community-maintained custom integration. It is **not** a
|
|||
- **Best/Peak hour detection** - Automatic detection of cheapest/peak periods with configurable filters ([learn how](period-calculation.md))
|
||||
- **Beautiful ApexCharts** - Auto-generated chart configurations with dynamic Y-axis scaling ([see examples](chart-examples.md))
|
||||
- **Chart metadata sensor** - Dynamic chart configuration for optimal visualization
|
||||
- **Multi-currency support** - EUR, NOK, SEK with proper minor units (ct, øre, öre)
|
||||
- **Flexible currency display** - Choose base currency (€, kr) or subunit (ct, øre) with smart defaults per currency
|
||||
|
||||
## 🔗 Useful Links
|
||||
|
||||
|
|
|
|||
|
|
@ -56,7 +56,7 @@ response_variable: chart_data
|
|||
| `day` | Days to include: yesterday, today, tomorrow | `["today", "tomorrow"]` |
|
||||
| `output_format` | `array_of_objects` or `array_of_arrays` | `array_of_objects` |
|
||||
| `resolution` | `interval` (15-min) or `hourly` | `interval` |
|
||||
| `minor_currency` | Return prices in ct/øre instead of EUR/NOK | `false` |
|
||||
| `subunit_currency` | Return prices in ct/øre instead of EUR/NOK | `false` |
|
||||
| `round_decimals` | Decimal places (0-10) | 4 (major) or 2 (minor) |
|
||||
|
||||
**Rolling Window Mode:**
|
||||
|
|
|
|||
|
|
@ -138,18 +138,18 @@ class TestSegmentBoundaryDetection:
|
|||
class TestPriceConversion:
|
||||
"""Test price conversion logic used in connect_segments."""
|
||||
|
||||
def test_minor_currency_conversion(self) -> None:
|
||||
"""Test conversion to minor currency (cents/øre)."""
|
||||
def test_subunit_currency_conversion(self) -> None:
|
||||
"""Test conversion to subunit currency (cents/øre)."""
|
||||
price = 0.12 # EUR
|
||||
minor_currency = True
|
||||
converted = round(price * 100, 2) if minor_currency else round(price, 4)
|
||||
subunit_currency = True
|
||||
converted = round(price * 100, 2) if subunit_currency else round(price, 4)
|
||||
assert converted == 12.0, "0.12 EUR should be 12 cents"
|
||||
|
||||
def test_major_currency_rounding(self) -> None:
|
||||
"""Test major currency precision."""
|
||||
def test_base_currency_rounding(self) -> None:
|
||||
"""Test base currency precision."""
|
||||
price = 0.123456
|
||||
minor_currency = False
|
||||
converted = round(price * 100, 2) if minor_currency else round(price, 4)
|
||||
subunit_currency = False
|
||||
converted = round(price * 100, 2) if subunit_currency else round(price, 4)
|
||||
assert converted == 0.1235, "Should round to 4 decimal places"
|
||||
|
||||
def test_custom_rounding(self) -> None:
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ def test_period_array_of_arrays_with_insert_nulls() -> None:
|
|||
period = {
|
||||
"start": datetime(2025, 12, 3, 10, 0, tzinfo=UTC),
|
||||
"end": datetime(2025, 12, 3, 12, 0, tzinfo=UTC),
|
||||
"price_median": 1250, # Stored in minor units (12.50 EUR/ct)
|
||||
"price_median": 12.50, # Stored in major units (12.50 EUR)
|
||||
"level": "CHEAP",
|
||||
"rating_level": "LOW",
|
||||
}
|
||||
|
|
@ -39,11 +39,11 @@ def test_period_array_of_arrays_with_insert_nulls() -> None:
|
|||
|
||||
# Point 1: Start with price
|
||||
assert chart_data[0][0] == "2025-12-03T10:00:00+00:00"
|
||||
assert chart_data[0][1] == 1250
|
||||
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] == 1250
|
||||
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"
|
||||
|
|
@ -61,7 +61,7 @@ def test_period_array_of_arrays_without_insert_nulls() -> None:
|
|||
period = {
|
||||
"start": datetime(2025, 12, 3, 10, 0, tzinfo=UTC),
|
||||
"end": datetime(2025, 12, 3, 12, 0, tzinfo=UTC),
|
||||
"price_median": 1250,
|
||||
"price_median": 12.50,
|
||||
}
|
||||
|
||||
# Test with insert_nulls='none' (should NOT add NULL terminator)
|
||||
|
|
@ -78,8 +78,8 @@ def test_period_array_of_arrays_without_insert_nulls() -> 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] == 1250
|
||||
assert chart_data[1][1] == 1250
|
||||
assert chart_data[0][1] == 12.50
|
||||
assert chart_data[1][1] == 12.50
|
||||
|
||||
|
||||
def test_multiple_periods_separated_by_nulls() -> None:
|
||||
|
|
@ -92,12 +92,12 @@ def test_multiple_periods_separated_by_nulls() -> None:
|
|||
{
|
||||
"start": datetime(2025, 12, 3, 10, 0, tzinfo=UTC),
|
||||
"end": datetime(2025, 12, 3, 12, 0, tzinfo=UTC),
|
||||
"price_median": 1250,
|
||||
"price_median": 12.50,
|
||||
},
|
||||
{
|
||||
"start": datetime(2025, 12, 3, 15, 0, tzinfo=UTC),
|
||||
"end": datetime(2025, 12, 3, 17, 0, tzinfo=UTC),
|
||||
"price_median": 1850,
|
||||
"price_median": 18.50,
|
||||
},
|
||||
]
|
||||
|
||||
|
|
@ -121,7 +121,7 @@ def test_multiple_periods_separated_by_nulls() -> None:
|
|||
|
||||
# Period 2 starts
|
||||
assert chart_data[3][0] == "2025-12-03T15:00:00+00:00"
|
||||
assert chart_data[3][1] == 1850
|
||||
assert chart_data[3][1] == 18.50
|
||||
|
||||
# Period 2 ends with NULL
|
||||
assert chart_data[5][1] is None
|
||||
|
|
@ -137,12 +137,12 @@ def test_multiple_periods_without_nulls() -> None:
|
|||
{
|
||||
"start": datetime(2025, 12, 3, 10, 0, tzinfo=UTC),
|
||||
"end": datetime(2025, 12, 3, 12, 0, tzinfo=UTC),
|
||||
"price_median": 1250,
|
||||
"price_median": 12.50,
|
||||
},
|
||||
{
|
||||
"start": datetime(2025, 12, 3, 15, 0, tzinfo=UTC),
|
||||
"end": datetime(2025, 12, 3, 17, 0, tzinfo=UTC),
|
||||
"price_median": 1850,
|
||||
"price_median": 18.50,
|
||||
},
|
||||
]
|
||||
|
||||
|
|
@ -167,23 +167,23 @@ def test_multiple_periods_without_nulls() -> None:
|
|||
|
||||
def test_period_currency_conversion() -> None:
|
||||
"""
|
||||
Test that period prices are correctly converted between major/minor currency.
|
||||
Test that period prices are correctly converted between major/subunit currency.
|
||||
|
||||
Period prices are stored in minor units (ct/øre) in coordinator data.
|
||||
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": 1250, # 12.50 ct/øre
|
||||
"price_median": 12.50, # 12.50 €/kr (base currency)
|
||||
}
|
||||
|
||||
# Test 1: Keep minor currency (for ApexCharts internal use)
|
||||
price_minor = period["price_median"]
|
||||
assert price_minor == 1250, "Should keep minor units"
|
||||
# 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 major currency (for display)
|
||||
price_major = period["price_median"] / 100
|
||||
assert price_major == 12.50, "Should convert to 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:
|
||||
|
|
@ -195,7 +195,7 @@ def test_period_with_missing_end_time() -> None:
|
|||
period = {
|
||||
"start": datetime(2025, 12, 3, 10, 0, tzinfo=UTC),
|
||||
"end": None, # No end time
|
||||
"price_median": 1250,
|
||||
"price_median": 12.50,
|
||||
}
|
||||
|
||||
chart_data = []
|
||||
|
|
@ -216,7 +216,7 @@ def test_period_with_missing_end_time() -> 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] == 1250
|
||||
assert chart_data[0][1] == 12.50
|
||||
|
||||
|
||||
def test_apexcharts_mapping_preserves_structure() -> None:
|
||||
|
|
|
|||
|
|
@ -33,9 +33,10 @@ def test_trailing_avg_returns_none_when_empty() -> None:
|
|||
interval_start = datetime(2025, 11, 22, 12, 0, tzinfo=UTC)
|
||||
empty_prices: list[dict] = []
|
||||
|
||||
result = calculate_trailing_24h_avg(empty_prices, interval_start)
|
||||
avg, _median = calculate_trailing_24h_avg(empty_prices, interval_start)
|
||||
|
||||
assert result is None, "Empty price list should return None, not 0.0"
|
||||
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:
|
||||
|
|
@ -48,9 +49,10 @@ def test_leading_avg_returns_none_when_empty() -> None:
|
|||
interval_start = datetime(2025, 11, 22, 12, 0, tzinfo=UTC)
|
||||
empty_prices: list[dict] = []
|
||||
|
||||
result = calculate_leading_24h_avg(empty_prices, interval_start)
|
||||
avg, _median = calculate_leading_24h_avg(empty_prices, interval_start)
|
||||
|
||||
assert result is None, "Empty price list should return None, not 0.0"
|
||||
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:
|
||||
|
|
@ -65,13 +67,13 @@ def test_trailing_avg_returns_none_when_no_data_in_window(sample_prices: list[di
|
|||
# For example, 2 hours after the last data point
|
||||
interval_start = datetime(2025, 11, 22, 16, 0, tzinfo=UTC)
|
||||
|
||||
result = calculate_trailing_24h_avg(sample_prices, interval_start)
|
||||
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 result is not None, "Should find data in 24h trailing 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 result == pytest.approx(0.0), "Average should be 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:
|
||||
|
|
@ -85,11 +87,12 @@ def test_leading_avg_returns_none_when_no_data_in_window(sample_prices: list[dic
|
|||
# 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)
|
||||
|
||||
result = calculate_leading_24h_avg(sample_prices, interval_start)
|
||||
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 result is None, "Should return None when no data in 24h leading 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:
|
||||
|
|
@ -102,12 +105,12 @@ def test_trailing_avg_with_negative_prices_distinguishes_zero(sample_prices: lis
|
|||
# Use base_time where we have data
|
||||
interval_start = datetime(2025, 11, 22, 12, 0, tzinfo=UTC)
|
||||
|
||||
result = calculate_trailing_24h_avg(sample_prices, interval_start)
|
||||
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 result is not None, "Should return average when data exists"
|
||||
assert isinstance(result, float), "Should return float, not None"
|
||||
assert result != 0.0, "With negative prices, average should not be exactly 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, "With negative prices, average should not be exactly 0.0"
|
||||
|
||||
|
||||
def test_leading_avg_with_negative_prices_distinguishes_zero(sample_prices: list[dict]) -> None:
|
||||
|
|
@ -120,12 +123,12 @@ def test_leading_avg_with_negative_prices_distinguishes_zero(sample_prices: list
|
|||
# Use base_time - 2h to include all sample data in leading window
|
||||
interval_start = datetime(2025, 11, 22, 10, 0, tzinfo=UTC)
|
||||
|
||||
result = calculate_leading_24h_avg(sample_prices, interval_start)
|
||||
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 result is not None, "Should return average when data exists"
|
||||
assert isinstance(result, float), "Should return float, not None"
|
||||
assert result == 0.0, "Average of symmetric negative/positive prices should be 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:
|
||||
|
|
@ -142,11 +145,11 @@ def test_trailing_avg_with_all_negative_prices() -> None:
|
|||
{"startsAt": base_time - timedelta(hours=1), "total": -5.0},
|
||||
]
|
||||
|
||||
result = calculate_trailing_24h_avg(all_negative, base_time)
|
||||
avg, _median = calculate_trailing_24h_avg(all_negative, base_time)
|
||||
|
||||
assert result is not None, "Should return average for all negative prices"
|
||||
assert result < 0, "Average should be negative"
|
||||
assert result == pytest.approx(-10.0), "Average of -15, -10, -5 should be -10.0"
|
||||
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:
|
||||
|
|
@ -163,11 +166,11 @@ def test_leading_avg_with_all_negative_prices() -> None:
|
|||
{"startsAt": base_time + timedelta(hours=2), "total": -15.0},
|
||||
]
|
||||
|
||||
result = calculate_leading_24h_avg(all_negative, base_time)
|
||||
avg, _median = calculate_leading_24h_avg(all_negative, base_time)
|
||||
|
||||
assert result is not None, "Should return average for all negative prices"
|
||||
assert result < 0, "Average should be negative"
|
||||
assert result == pytest.approx(-10.0), "Average of -5, -10, -15 should be -10.0"
|
||||
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:
|
||||
|
|
@ -183,9 +186,10 @@ def test_trailing_avg_returns_none_with_none_timestamps() -> None:
|
|||
{"startsAt": None, "total": 20.0},
|
||||
]
|
||||
|
||||
result = calculate_trailing_24h_avg(prices_with_none, interval_start)
|
||||
avg, _median = calculate_trailing_24h_avg(prices_with_none, interval_start)
|
||||
|
||||
assert result is None, "Should return None when all timestamps are None"
|
||||
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:
|
||||
|
|
@ -201,6 +205,7 @@ def test_leading_avg_returns_none_with_none_timestamps() -> None:
|
|||
{"startsAt": None, "total": 20.0},
|
||||
]
|
||||
|
||||
result = calculate_leading_24h_avg(prices_with_none, interval_start)
|
||||
avg, _median = calculate_leading_24h_avg(prices_with_none, interval_start)
|
||||
|
||||
assert result is None, "Should return None when all timestamps are None"
|
||||
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"
|
||||
|
|
|
|||
|
|
@ -142,6 +142,7 @@ class TestBestPriceGenerationWorks:
|
|||
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", [])
|
||||
|
|
@ -181,6 +182,7 @@ class TestBestPriceGenerationWorks:
|
|||
max_relaxation_attempts=11,
|
||||
should_show_callback=lambda _: True,
|
||||
time=time_service,
|
||||
config_entry=mock_coordinator.config_entry,
|
||||
)
|
||||
|
||||
periods_pos = result_pos.get("periods", [])
|
||||
|
|
@ -218,6 +220,7 @@ class TestBestPriceGenerationWorks:
|
|||
max_relaxation_attempts=11,
|
||||
should_show_callback=lambda _: True,
|
||||
time=time_service,
|
||||
config_entry=mock_coordinator.config_entry,
|
||||
)
|
||||
|
||||
periods = result.get("periods", [])
|
||||
|
|
@ -227,7 +230,7 @@ class TestBestPriceGenerationWorks:
|
|||
# Check period averages are NOT near daily maximum
|
||||
# Note: period prices are in cents, daily stats are in euros
|
||||
for period in periods:
|
||||
period_avg = period.get("price_avg", 0)
|
||||
period_avg = period.get("price_mean", 0)
|
||||
assert period_avg < daily_max * 100 * 0.95, (
|
||||
f"Best period has too high avg: {period_avg:.4f} ct vs daily_max={daily_max * 100:.4f} ct"
|
||||
)
|
||||
|
|
@ -263,6 +266,7 @@ class TestBestPriceGenerationWorks:
|
|||
max_relaxation_attempts=11,
|
||||
should_show_callback=lambda _: True,
|
||||
time=time_service,
|
||||
config_entry=mock_coordinator.config_entry,
|
||||
)
|
||||
|
||||
periods = result.get("periods", [])
|
||||
|
|
@ -313,22 +317,25 @@ class TestBestPriceBugRegressionValidation:
|
|||
max_relaxation_attempts=11,
|
||||
should_show_callback=lambda _: True,
|
||||
time=time_service,
|
||||
config_entry=mock_coordinator.config_entry,
|
||||
)
|
||||
|
||||
# Check metadata from result
|
||||
# Check result metadata
|
||||
# Check that relaxation didn't max out at 50%
|
||||
metadata = result.get("metadata", {})
|
||||
config_used = metadata.get("config", {})
|
||||
|
||||
if "flex" in config_used:
|
||||
flex_used = config_used["flex"]
|
||||
# Reasonable flex should be sufficient
|
||||
assert 0.10 <= flex_used <= 0.35, f"Expected flex 10-35%, got {flex_used * 100:.1f}%"
|
||||
# Reasonable flex should be sufficient (not maxing out at 50%)
|
||||
assert 0.10 <= flex_used <= 0.48, f"Expected flex 10-48%, got {flex_used * 100:.1f}%"
|
||||
|
||||
# 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}%"
|
||||
# Should not max out at 50%
|
||||
assert max_flex <= 0.48, f"Max flex should be reasonable, got {max_flex * 100:.1f}%"
|
||||
|
||||
def test_periods_include_cheap_intervals(self) -> None:
|
||||
"""
|
||||
|
|
@ -360,6 +367,7 @@ class TestBestPriceBugRegressionValidation:
|
|||
max_relaxation_attempts=11,
|
||||
should_show_callback=lambda _: True,
|
||||
time=time_service,
|
||||
config_entry=mock_coordinator.config_entry,
|
||||
)
|
||||
|
||||
periods = result.get("periods", [])
|
||||
|
|
@ -369,7 +377,7 @@ class TestBestPriceBugRegressionValidation:
|
|||
|
||||
# At least one period should have low average
|
||||
# Note: period prices are in cents, daily stats are in euros
|
||||
min_period_avg = min(p.get("price_avg", 1.0) for p in periods)
|
||||
min_period_avg = min(p.get("price_mean", 1.0) for p in periods)
|
||||
|
||||
assert min_period_avg <= daily_avg * 100 * 0.95, (
|
||||
f"Best periods should have low avg: {min_period_avg:.4f} ct vs daily_avg={daily_avg * 100:.4f} ct"
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ def test_cache_valid_same_day() -> None:
|
|||
time_service.as_local.side_effect = lambda dt: dt
|
||||
|
||||
cache_data = TibberPricesCacheData(
|
||||
price_data={"priceInfo": {"today": [1, 2, 3]}},
|
||||
price_data={"price_info": [1, 2, 3]},
|
||||
user_data={"viewer": {"home": {"id": "test"}}},
|
||||
last_price_update=cache_time,
|
||||
last_user_update=cache_time,
|
||||
|
|
@ -63,7 +63,7 @@ def test_cache_invalid_different_day() -> None:
|
|||
time_service.as_local.side_effect = lambda dt: dt
|
||||
|
||||
cache_data = TibberPricesCacheData(
|
||||
price_data={"priceInfo": {"today": [1, 2, 3]}},
|
||||
price_data={"price_info": [1, 2, 3]},
|
||||
user_data={"viewer": {"home": {"id": "test"}}},
|
||||
last_price_update=cache_time,
|
||||
last_user_update=cache_time,
|
||||
|
|
@ -117,7 +117,7 @@ def test_cache_invalid_no_last_update() -> None:
|
|||
time_service.as_local.side_effect = lambda dt: dt
|
||||
|
||||
cache_data = TibberPricesCacheData(
|
||||
price_data={"priceInfo": {"today": [1, 2, 3]}},
|
||||
price_data={"price_info": [1, 2, 3]},
|
||||
user_data={"viewer": {"home": {"id": "test"}}},
|
||||
last_price_update=None, # No timestamp!
|
||||
last_user_update=None,
|
||||
|
|
@ -149,7 +149,7 @@ def test_cache_valid_after_midnight_turnover() -> None:
|
|||
time_service.as_local.side_effect = lambda dt: dt
|
||||
|
||||
cache_data = TibberPricesCacheData(
|
||||
price_data={"priceInfo": {"yesterday": [1], "today": [2], "tomorrow": []}},
|
||||
price_data={"price_info": [1, 2]},
|
||||
user_data={"viewer": {"home": {"id": "test"}}},
|
||||
last_price_update=turnover_time, # Updated during turnover!
|
||||
last_user_update=turnover_time,
|
||||
|
|
@ -177,7 +177,7 @@ def test_cache_invalid_midnight_crossing_without_update() -> None:
|
|||
time_service.as_local.side_effect = lambda dt: dt
|
||||
|
||||
cache_data = TibberPricesCacheData(
|
||||
price_data={"priceInfo": {"today": [1, 2, 3]}},
|
||||
price_data={"price_info": [1, 2, 3]},
|
||||
user_data={"viewer": {"home": {"id": "test"}}},
|
||||
last_price_update=cache_time, # Still yesterday!
|
||||
last_user_update=cache_time,
|
||||
|
|
@ -214,7 +214,7 @@ def test_cache_validity_timezone_aware() -> None:
|
|||
time_service.as_local.return_value = current_time_local
|
||||
|
||||
cache_data = TibberPricesCacheData(
|
||||
price_data={"priceInfo": {"today": [1, 2, 3]}},
|
||||
price_data={"price_info": [1, 2, 3]},
|
||||
user_data={"viewer": {"home": {"id": "test"}}},
|
||||
last_price_update=cache_time_utc,
|
||||
last_user_update=cache_time_utc,
|
||||
|
|
@ -251,7 +251,7 @@ def test_cache_validity_exact_midnight_boundary() -> None:
|
|||
time_service.as_local.side_effect = lambda dt: dt
|
||||
|
||||
cache_data = TibberPricesCacheData(
|
||||
price_data={"priceInfo": {"today": [1, 2, 3]}},
|
||||
price_data={"price_info": [1, 2, 3]},
|
||||
user_data={"viewer": {"home": {"id": "test"}}},
|
||||
last_price_update=cache_time,
|
||||
last_user_update=cache_time,
|
||||
|
|
|
|||
|
|
@ -20,11 +20,14 @@ async def test_coordinator_shutdown_saves_cache() -> None:
|
|||
# Create mock coordinator bypassing __init__
|
||||
coordinator = object.__new__(TibberPricesDataUpdateCoordinator)
|
||||
|
||||
# Mock the _store_cache method and listener manager
|
||||
# Mock the _store_cache method, listener manager, and repair manager
|
||||
coordinator._store_cache = AsyncMock() # noqa: SLF001
|
||||
mock_manager = MagicMock()
|
||||
mock_manager.cancel_timers = MagicMock()
|
||||
coordinator._listener_manager = mock_manager # noqa: SLF001
|
||||
mock_listener_manager = MagicMock()
|
||||
mock_listener_manager.cancel_timers = MagicMock()
|
||||
coordinator._listener_manager = mock_listener_manager # noqa: SLF001
|
||||
mock_repair_manager = MagicMock()
|
||||
mock_repair_manager.clear_all_repairs = AsyncMock()
|
||||
coordinator._repair_manager = mock_repair_manager # noqa: SLF001
|
||||
coordinator._log = lambda *_a, **_kw: None # noqa: SLF001
|
||||
|
||||
# Call shutdown
|
||||
|
|
@ -32,8 +35,10 @@ async def test_coordinator_shutdown_saves_cache() -> None:
|
|||
|
||||
# Verify cache was saved
|
||||
coordinator._store_cache.assert_called_once() # noqa: SLF001
|
||||
# Verify repairs were cleared
|
||||
mock_repair_manager.clear_all_repairs.assert_called_once()
|
||||
# Verify timers were cancelled
|
||||
mock_manager.cancel_timers.assert_called_once()
|
||||
mock_listener_manager.cancel_timers.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
@ -48,9 +53,12 @@ async def test_coordinator_shutdown_handles_cache_error() -> None:
|
|||
|
||||
# Mock _store_cache to raise an exception
|
||||
coordinator._store_cache = AsyncMock(side_effect=OSError("Disk full")) # noqa: SLF001
|
||||
mock_manager = MagicMock()
|
||||
mock_manager.cancel_timers = MagicMock()
|
||||
coordinator._listener_manager = mock_manager # noqa: SLF001
|
||||
mock_listener_manager = MagicMock()
|
||||
mock_listener_manager.cancel_timers = MagicMock()
|
||||
coordinator._listener_manager = mock_listener_manager # noqa: SLF001
|
||||
mock_repair_manager = MagicMock()
|
||||
mock_repair_manager.clear_all_repairs = AsyncMock()
|
||||
coordinator._repair_manager = mock_repair_manager # noqa: SLF001
|
||||
coordinator._log = lambda *_a, **_kw: None # noqa: SLF001
|
||||
|
||||
# Shutdown should complete without raising
|
||||
|
|
@ -59,4 +67,4 @@ async def test_coordinator_shutdown_handles_cache_error() -> None:
|
|||
# Verify _store_cache was called (even though it raised)
|
||||
coordinator._store_cache.assert_called_once() # noqa: SLF001
|
||||
# Verify timers were still cancelled despite error
|
||||
mock_manager.cancel_timers.assert_called_once()
|
||||
mock_listener_manager.cancel_timers.assert_called_once()
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from unittest.mock import Mock
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import pytest
|
||||
|
|
@ -90,12 +91,21 @@ def test_midnight_crossing_period_consistency(period_config: TibberPricesPeriodC
|
|||
tz = ZoneInfo("Europe/Berlin")
|
||||
yesterday_prices, today_prices, tomorrow_prices, day_after_tomorrow_prices = create_price_data_scenario()
|
||||
|
||||
# Create mock config entry
|
||||
mock_config_entry = Mock()
|
||||
mock_config_entry.options.get.return_value = "minor"
|
||||
|
||||
# SCENARIO 1: Before midnight (today = 2025-11-21 22:00)
|
||||
current_time_before = datetime(2025, 11, 21, 22, 0, 0, tzinfo=tz)
|
||||
time_service_before = TibberPricesTimeService(current_time_before)
|
||||
all_prices_before = yesterday_prices + today_prices + tomorrow_prices
|
||||
|
||||
result_before = calculate_periods(all_prices_before, config=period_config, time=time_service_before)
|
||||
result_before = calculate_periods(
|
||||
all_prices_before,
|
||||
config=period_config,
|
||||
time=time_service_before,
|
||||
config_entry=mock_config_entry,
|
||||
)
|
||||
periods_before = result_before["periods"]
|
||||
|
||||
# Find the midnight-crossing period (starts 21st, ends 22nd)
|
||||
|
|
@ -117,7 +127,12 @@ def test_midnight_crossing_period_consistency(period_config: TibberPricesPeriodC
|
|||
tomorrow_after_turnover = day_after_tomorrow_prices
|
||||
all_prices_after = yesterday_after_turnover + today_after_turnover + tomorrow_after_turnover
|
||||
|
||||
result_after = calculate_periods(all_prices_after, config=period_config, time=time_service_after)
|
||||
result_after = calculate_periods(
|
||||
all_prices_after,
|
||||
config=period_config,
|
||||
time=time_service_after,
|
||||
config_entry=mock_config_entry,
|
||||
)
|
||||
periods_after = result_after["periods"]
|
||||
|
||||
# Find period that started on 2025-11-21 (now "yesterday")
|
||||
|
|
|
|||
|
|
@ -143,6 +143,7 @@ class TestPeakPriceGenerationWorks:
|
|||
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", [])
|
||||
|
|
@ -183,6 +184,7 @@ class TestPeakPriceGenerationWorks:
|
|||
max_relaxation_attempts=11,
|
||||
should_show_callback=lambda _: True,
|
||||
time=time_service,
|
||||
config_entry=mock_coordinator.config_entry,
|
||||
)
|
||||
|
||||
periods_pos = result_pos.get("periods", [])
|
||||
|
|
@ -220,6 +222,7 @@ class TestPeakPriceGenerationWorks:
|
|||
max_relaxation_attempts=11,
|
||||
should_show_callback=lambda _: True,
|
||||
time=time_service,
|
||||
config_entry=mock_coordinator.config_entry,
|
||||
)
|
||||
|
||||
periods = result.get("periods", [])
|
||||
|
|
@ -228,7 +231,7 @@ class TestPeakPriceGenerationWorks:
|
|||
|
||||
# Check period averages are NOT near daily minimum
|
||||
for period in periods:
|
||||
period_avg = period.get("price_avg", 0)
|
||||
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}"
|
||||
)
|
||||
|
|
@ -264,6 +267,7 @@ class TestPeakPriceGenerationWorks:
|
|||
max_relaxation_attempts=11,
|
||||
should_show_callback=lambda _: True,
|
||||
time=time_service,
|
||||
config_entry=mock_coordinator.config_entry,
|
||||
)
|
||||
|
||||
periods = result.get("periods", [])
|
||||
|
|
@ -315,6 +319,7 @@ class TestBugRegressionValidation:
|
|||
max_relaxation_attempts=11,
|
||||
should_show_callback=lambda _: True,
|
||||
time=time_service,
|
||||
config_entry=mock_coordinator.config_entry,
|
||||
)
|
||||
|
||||
# Check metadata from result
|
||||
|
|
@ -366,6 +371,7 @@ class TestBugRegressionValidation:
|
|||
max_relaxation_attempts=11,
|
||||
should_show_callback=lambda _: True,
|
||||
time=time_service,
|
||||
config_entry=mock_coordinator.config_entry,
|
||||
)
|
||||
|
||||
periods = result.get("periods", [])
|
||||
|
|
@ -374,7 +380,7 @@ class TestBugRegressionValidation:
|
|||
daily_max = intervals[0]["daily_max"]
|
||||
|
||||
# At least one period should have high average
|
||||
max_period_avg = max(p.get("price_avg", 0) for p in periods)
|
||||
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}"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
"""Test Bug #9, #10, #11: Percentage calculations with negative prices use abs() correctly."""
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
|
||||
|
|
@ -40,7 +41,12 @@ def test_bug9_period_price_diff_negative_reference(price_context_negative: dict)
|
|||
start_time = datetime(2025, 11, 22, 12, 0, tzinfo=UTC)
|
||||
price_avg = -10.0 # Period average in minor units (ct)
|
||||
|
||||
period_diff, period_diff_pct = calculate_period_price_diff(price_avg, start_time, price_context_negative)
|
||||
mock_config_entry = Mock()
|
||||
mock_config_entry.options.get.return_value = "minor" # Default display mode
|
||||
|
||||
period_diff, period_diff_pct = calculate_period_price_diff(
|
||||
price_avg, start_time, price_context_negative, mock_config_entry
|
||||
)
|
||||
|
||||
# Reference price: -20 ct
|
||||
# Difference: -10 - (-20) = 10 ct (period is 10 ct MORE EXPENSIVE than reference)
|
||||
|
|
@ -59,7 +65,12 @@ def test_bug9_period_price_diff_more_negative_than_reference(price_context_negat
|
|||
start_time = datetime(2025, 11, 22, 12, 0, tzinfo=UTC)
|
||||
price_avg = -25.0 # More negative (cheaper) than reference -20 ct
|
||||
|
||||
period_diff, period_diff_pct = calculate_period_price_diff(price_avg, start_time, price_context_negative)
|
||||
mock_config_entry = Mock()
|
||||
mock_config_entry.options.get.return_value = "minor" # Default display mode
|
||||
|
||||
period_diff, period_diff_pct = calculate_period_price_diff(
|
||||
price_avg, start_time, price_context_negative, mock_config_entry
|
||||
)
|
||||
|
||||
# Reference: -20 ct
|
||||
# Difference: -25 - (-20) = -5 ct (period is 5 ct CHEAPER)
|
||||
|
|
@ -77,7 +88,12 @@ def test_bug9_period_price_diff_positive_reference(price_context_positive: dict)
|
|||
start_time = datetime(2025, 11, 22, 12, 0, tzinfo=UTC)
|
||||
price_avg = 30.0 # ct
|
||||
|
||||
period_diff, period_diff_pct = calculate_period_price_diff(price_avg, start_time, price_context_positive)
|
||||
mock_config_entry = Mock()
|
||||
mock_config_entry.options.get.return_value = "minor" # Default display mode
|
||||
|
||||
period_diff, period_diff_pct = calculate_period_price_diff(
|
||||
price_avg, start_time, price_context_positive, mock_config_entry
|
||||
)
|
||||
|
||||
# Reference: 20 ct
|
||||
# Difference: 30 - 20 = 10 ct
|
||||
|
|
|
|||
|
|
@ -406,7 +406,7 @@ def test_timer_constants_are_comprehensive() -> None:
|
|||
known_exceptions = {
|
||||
"data_last_updated", # Timestamp of last update, not time-dependent
|
||||
"next_24h_volatility", # Uses fixed 24h window from current time, updated on API data
|
||||
"current_interval_price_major", # Duplicate of current_interval_price (just different unit)
|
||||
"current_interval_price_base", # Duplicate of current_interval_price (just different unit)
|
||||
"best_price_period_duration", # Duration in minutes, doesn't change minute-by-minute
|
||||
"peak_price_period_duration", # Duration in minutes, doesn't change minute-by-minute
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue