mirror of
https://github.com/jpawlowski/hass.tibber_prices.git
synced 2026-07-28 01:26:49 +00:00
Compare commits
10 commits
8aa5769784
...
4ddd19b132
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4ddd19b132 | ||
|
|
e44f639b41 | ||
|
|
b7f1efce1f | ||
|
|
447dc907e6 | ||
|
|
999ecd358f | ||
|
|
f6a49d9cf3 | ||
|
|
4cc150df6f | ||
|
|
83ec3910bd | ||
|
|
9142f87abd | ||
|
|
6e0613c055 |
74 changed files with 16569 additions and 7155 deletions
|
|
@ -77,6 +77,14 @@
|
|||
"python.testing.pytestArgs": [
|
||||
"--no-cov"
|
||||
],
|
||||
"[json]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"editor.tabSize": 2
|
||||
},
|
||||
"[jsonc]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"editor.tabSize": 2
|
||||
},
|
||||
"[python]": {
|
||||
"editor.defaultFormatter": "charliermarsh.ruff",
|
||||
"editor.formatOnSave": true,
|
||||
|
|
|
|||
|
|
@ -13,5 +13,8 @@ indent_size = 4
|
|||
# Python style aligns with Black
|
||||
indent_size = 4
|
||||
|
||||
[*.json]
|
||||
indent_size = 2
|
||||
|
||||
[*.md]
|
||||
trim_trailing_whitespace = false
|
||||
|
|
|
|||
|
|
@ -257,6 +257,52 @@ def add_detail_attributes(attributes: dict, current_period: dict) -> None:
|
|||
attributes["periods_remaining"] = current_period["periods_remaining"]
|
||||
|
||||
|
||||
def add_period_count_attributes(
|
||||
attributes: dict,
|
||||
period_summaries: list[dict],
|
||||
time: TibberPricesTimeService,
|
||||
) -> None:
|
||||
"""
|
||||
Add per-day period count attributes (priority 5.5).
|
||||
|
||||
Counts how many periods fall on today and tomorrow so automations can check
|
||||
things like "only charge if there are at least 2 cheap periods today".
|
||||
|
||||
Args:
|
||||
attributes: Target dict to add attributes to
|
||||
period_summaries: All period summaries (already filtered to today+tomorrow)
|
||||
time: TibberPricesTimeService instance for date comparison
|
||||
|
||||
"""
|
||||
now = time.now()
|
||||
today = time.get_local_date()
|
||||
tomorrow = time.get_local_date(offset_days=1)
|
||||
|
||||
count_today = 0
|
||||
count_tomorrow = 0
|
||||
|
||||
for period in period_summaries:
|
||||
start = period.get("start")
|
||||
if start is None:
|
||||
continue
|
||||
if hasattr(start, "date"):
|
||||
period_date = start.date()
|
||||
else:
|
||||
from datetime import datetime # noqa: PLC0415
|
||||
|
||||
period_date = datetime.fromisoformat(str(start)).date()
|
||||
|
||||
if period_date == today:
|
||||
count_today += 1
|
||||
elif period_date == tomorrow:
|
||||
count_tomorrow += 1
|
||||
|
||||
_ = now # used for clarity only
|
||||
if count_today > 0 or count_tomorrow > 0:
|
||||
attributes["period_count_today"] = count_today
|
||||
attributes["period_count_tomorrow"] = count_tomorrow
|
||||
|
||||
|
||||
def add_relaxation_attributes(attributes: dict, current_period: dict) -> None:
|
||||
"""
|
||||
Add relaxation information attributes (priority 6).
|
||||
|
|
@ -412,6 +458,9 @@ def build_final_attributes_simple(
|
|||
# 5. Detail information
|
||||
add_detail_attributes(attributes, current_period)
|
||||
|
||||
# 5.5 Per-day period counts (how many cheap/peak periods per day)
|
||||
add_period_count_attributes(attributes, period_summaries, time)
|
||||
|
||||
# 6. Relaxation information (only if current period was relaxed)
|
||||
add_relaxation_attributes(attributes, current_period)
|
||||
|
||||
|
|
@ -429,6 +478,7 @@ def build_final_attributes_simple(
|
|||
result: dict = {
|
||||
"timestamp": timestamp,
|
||||
}
|
||||
add_period_count_attributes(result, period_summaries, time)
|
||||
if period_metadata:
|
||||
add_calculation_summary_attributes(result, period_metadata)
|
||||
result["periods"] = _convert_periods_to_display_units(period_summaries, factor)
|
||||
|
|
|
|||
|
|
@ -12,7 +12,10 @@ import voluptuous as vol
|
|||
from custom_components.tibber_prices.const import (
|
||||
BEST_PRICE_MAX_LEVEL_OPTIONS,
|
||||
CONF_AVERAGE_SENSOR_DISPLAY,
|
||||
CONF_BEST_PRICE_EXTEND_TO_VERY_CHEAP,
|
||||
CONF_BEST_PRICE_FLEX,
|
||||
CONF_BEST_PRICE_GEOMETRIC_FLEX,
|
||||
CONF_BEST_PRICE_MAX_EXTENSION_INTERVALS,
|
||||
CONF_BEST_PRICE_MAX_LEVEL,
|
||||
CONF_BEST_PRICE_MAX_LEVEL_GAP_COUNT,
|
||||
CONF_BEST_PRICE_MIN_DISTANCE_FROM_AVG,
|
||||
|
|
@ -23,7 +26,10 @@ from custom_components.tibber_prices.const import (
|
|||
CONF_EXTENDED_DESCRIPTIONS,
|
||||
CONF_MIN_PERIODS_BEST,
|
||||
CONF_MIN_PERIODS_PEAK,
|
||||
CONF_PEAK_PRICE_EXTEND_TO_VERY_EXPENSIVE,
|
||||
CONF_PEAK_PRICE_FLEX,
|
||||
CONF_PEAK_PRICE_GEOMETRIC_FLEX,
|
||||
CONF_PEAK_PRICE_MAX_EXTENSION_INTERVALS,
|
||||
CONF_PEAK_PRICE_MAX_LEVEL_GAP_COUNT,
|
||||
CONF_PEAK_PRICE_MIN_DISTANCE_FROM_AVG,
|
||||
CONF_PEAK_PRICE_MIN_LEVEL,
|
||||
|
|
@ -49,7 +55,10 @@ from custom_components.tibber_prices.const import (
|
|||
CONF_VOLATILITY_THRESHOLD_MODERATE,
|
||||
CONF_VOLATILITY_THRESHOLD_VERY_HIGH,
|
||||
DEFAULT_AVERAGE_SENSOR_DISPLAY,
|
||||
DEFAULT_BEST_PRICE_EXTEND_TO_VERY_CHEAP,
|
||||
DEFAULT_BEST_PRICE_FLEX,
|
||||
DEFAULT_BEST_PRICE_GEOMETRIC_FLEX,
|
||||
DEFAULT_BEST_PRICE_MAX_EXTENSION_INTERVALS,
|
||||
DEFAULT_BEST_PRICE_MAX_LEVEL,
|
||||
DEFAULT_BEST_PRICE_MAX_LEVEL_GAP_COUNT,
|
||||
DEFAULT_BEST_PRICE_MIN_DISTANCE_FROM_AVG,
|
||||
|
|
@ -59,7 +68,10 @@ from custom_components.tibber_prices.const import (
|
|||
DEFAULT_EXTENDED_DESCRIPTIONS,
|
||||
DEFAULT_MIN_PERIODS_BEST,
|
||||
DEFAULT_MIN_PERIODS_PEAK,
|
||||
DEFAULT_PEAK_PRICE_EXTEND_TO_VERY_EXPENSIVE,
|
||||
DEFAULT_PEAK_PRICE_FLEX,
|
||||
DEFAULT_PEAK_PRICE_GEOMETRIC_FLEX,
|
||||
DEFAULT_PEAK_PRICE_MAX_EXTENSION_INTERVALS,
|
||||
DEFAULT_PEAK_PRICE_MAX_LEVEL_GAP_COUNT,
|
||||
DEFAULT_PEAK_PRICE_MIN_DISTANCE_FROM_AVG,
|
||||
DEFAULT_PEAK_PRICE_MIN_LEVEL,
|
||||
|
|
@ -86,7 +98,9 @@ from custom_components.tibber_prices.const import (
|
|||
DEFAULT_VOLATILITY_THRESHOLD_VERY_HIGH,
|
||||
DISPLAY_MODE_BASE,
|
||||
DISPLAY_MODE_SUBUNIT,
|
||||
MAX_EXTENSION_INTERVALS,
|
||||
MAX_GAP_COUNT,
|
||||
MAX_GEOMETRIC_FLEX,
|
||||
MAX_MIN_PERIOD_LENGTH,
|
||||
MAX_MIN_PERIODS,
|
||||
MAX_PRICE_LEVEL_GAP_TOLERANCE,
|
||||
|
|
@ -618,6 +632,7 @@ def get_best_price_schema(
|
|||
period_settings = options.get("period_settings", {})
|
||||
flexibility_settings = options.get("flexibility_settings", {})
|
||||
relaxation_settings = options.get("relaxation_and_target_periods", {})
|
||||
extension_settings = options.get("extension_settings", {})
|
||||
|
||||
# Get current values for override display
|
||||
min_period_length = int(
|
||||
|
|
@ -633,6 +648,13 @@ def get_best_price_schema(
|
|||
enable_min_periods = relaxation_settings.get(CONF_ENABLE_MIN_PERIODS_BEST, DEFAULT_ENABLE_MIN_PERIODS_BEST)
|
||||
min_periods = int(relaxation_settings.get(CONF_MIN_PERIODS_BEST, DEFAULT_MIN_PERIODS_BEST))
|
||||
relaxation_attempts = int(relaxation_settings.get(CONF_RELAXATION_ATTEMPTS_BEST, DEFAULT_RELAXATION_ATTEMPTS_BEST))
|
||||
extend_to_very_cheap = bool(
|
||||
extension_settings.get(CONF_BEST_PRICE_EXTEND_TO_VERY_CHEAP, DEFAULT_BEST_PRICE_EXTEND_TO_VERY_CHEAP)
|
||||
)
|
||||
max_extension_intervals_best = int(
|
||||
extension_settings.get(CONF_BEST_PRICE_MAX_EXTENSION_INTERVALS, DEFAULT_BEST_PRICE_MAX_EXTENSION_INTERVALS)
|
||||
)
|
||||
geometric_flex_best = int(extension_settings.get(CONF_BEST_PRICE_GEOMETRIC_FLEX, DEFAULT_BEST_PRICE_GEOMETRIC_FLEX))
|
||||
|
||||
# Build section schemas with optional override warnings
|
||||
period_warning = get_section_override_warning("best_price", "period_settings", overrides, translations) or {}
|
||||
|
|
@ -754,6 +776,40 @@ def get_best_price_schema(
|
|||
vol.Schema(relaxation_fields),
|
||||
{"collapsed": True},
|
||||
),
|
||||
vol.Required("extension_settings"): section(
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Optional(
|
||||
CONF_BEST_PRICE_EXTEND_TO_VERY_CHEAP,
|
||||
default=extend_to_very_cheap,
|
||||
): BooleanSelector(selector.BooleanSelectorConfig()),
|
||||
vol.Optional(
|
||||
CONF_BEST_PRICE_MAX_EXTENSION_INTERVALS,
|
||||
default=max_extension_intervals_best,
|
||||
): NumberSelector(
|
||||
NumberSelectorConfig(
|
||||
min=1,
|
||||
max=MAX_EXTENSION_INTERVALS,
|
||||
step=1,
|
||||
mode=NumberSelectorMode.SLIDER,
|
||||
)
|
||||
),
|
||||
vol.Optional(
|
||||
CONF_BEST_PRICE_GEOMETRIC_FLEX,
|
||||
default=geometric_flex_best,
|
||||
): NumberSelector(
|
||||
NumberSelectorConfig(
|
||||
min=0,
|
||||
max=MAX_GEOMETRIC_FLEX,
|
||||
step=1,
|
||||
unit_of_measurement="%",
|
||||
mode=NumberSelectorMode.SLIDER,
|
||||
)
|
||||
),
|
||||
}
|
||||
),
|
||||
{"collapsed": True},
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
|
|
@ -779,6 +835,7 @@ def get_peak_price_schema(
|
|||
period_settings = options.get("period_settings", {})
|
||||
flexibility_settings = options.get("flexibility_settings", {})
|
||||
relaxation_settings = options.get("relaxation_and_target_periods", {})
|
||||
extension_settings = options.get("extension_settings", {})
|
||||
|
||||
# Get current values for override display
|
||||
min_period_length = int(
|
||||
|
|
@ -794,6 +851,13 @@ def get_peak_price_schema(
|
|||
enable_min_periods = relaxation_settings.get(CONF_ENABLE_MIN_PERIODS_PEAK, DEFAULT_ENABLE_MIN_PERIODS_PEAK)
|
||||
min_periods = int(relaxation_settings.get(CONF_MIN_PERIODS_PEAK, DEFAULT_MIN_PERIODS_PEAK))
|
||||
relaxation_attempts = int(relaxation_settings.get(CONF_RELAXATION_ATTEMPTS_PEAK, DEFAULT_RELAXATION_ATTEMPTS_PEAK))
|
||||
extend_to_very_expensive = bool(
|
||||
extension_settings.get(CONF_PEAK_PRICE_EXTEND_TO_VERY_EXPENSIVE, DEFAULT_PEAK_PRICE_EXTEND_TO_VERY_EXPENSIVE)
|
||||
)
|
||||
max_extension_intervals_peak = int(
|
||||
extension_settings.get(CONF_PEAK_PRICE_MAX_EXTENSION_INTERVALS, DEFAULT_PEAK_PRICE_MAX_EXTENSION_INTERVALS)
|
||||
)
|
||||
geometric_flex_peak = int(extension_settings.get(CONF_PEAK_PRICE_GEOMETRIC_FLEX, DEFAULT_PEAK_PRICE_GEOMETRIC_FLEX))
|
||||
|
||||
# Build section schemas with optional override warnings
|
||||
period_warning = get_section_override_warning("peak_price", "period_settings", overrides, translations) or {}
|
||||
|
|
@ -915,6 +979,40 @@ def get_peak_price_schema(
|
|||
vol.Schema(relaxation_fields),
|
||||
{"collapsed": True},
|
||||
),
|
||||
vol.Required("extension_settings"): section(
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Optional(
|
||||
CONF_PEAK_PRICE_EXTEND_TO_VERY_EXPENSIVE,
|
||||
default=extend_to_very_expensive,
|
||||
): BooleanSelector(selector.BooleanSelectorConfig()),
|
||||
vol.Optional(
|
||||
CONF_PEAK_PRICE_MAX_EXTENSION_INTERVALS,
|
||||
default=max_extension_intervals_peak,
|
||||
): NumberSelector(
|
||||
NumberSelectorConfig(
|
||||
min=1,
|
||||
max=MAX_EXTENSION_INTERVALS,
|
||||
step=1,
|
||||
mode=NumberSelectorMode.SLIDER,
|
||||
)
|
||||
),
|
||||
vol.Optional(
|
||||
CONF_PEAK_PRICE_GEOMETRIC_FLEX,
|
||||
default=geometric_flex_peak,
|
||||
): NumberSelector(
|
||||
NumberSelectorConfig(
|
||||
min=0,
|
||||
max=MAX_GEOMETRIC_FLEX,
|
||||
step=1,
|
||||
unit_of_measurement="%",
|
||||
mode=NumberSelectorMode.SLIDER,
|
||||
)
|
||||
),
|
||||
}
|
||||
),
|
||||
{"collapsed": True},
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -68,6 +68,12 @@ CONF_RELAXATION_ATTEMPTS_BEST = "relaxation_attempts_best"
|
|||
CONF_ENABLE_MIN_PERIODS_PEAK = "enable_min_periods_peak"
|
||||
CONF_MIN_PERIODS_PEAK = "min_periods_peak"
|
||||
CONF_RELAXATION_ATTEMPTS_PEAK = "relaxation_attempts_peak"
|
||||
CONF_BEST_PRICE_EXTEND_TO_VERY_CHEAP = "best_price_extend_to_very_cheap"
|
||||
CONF_BEST_PRICE_MAX_EXTENSION_INTERVALS = "best_price_max_extension_intervals"
|
||||
CONF_BEST_PRICE_GEOMETRIC_FLEX = "best_price_geometric_flex"
|
||||
CONF_PEAK_PRICE_EXTEND_TO_VERY_EXPENSIVE = "peak_price_extend_to_very_expensive"
|
||||
CONF_PEAK_PRICE_MAX_EXTENSION_INTERVALS = "peak_price_max_extension_intervals"
|
||||
CONF_PEAK_PRICE_GEOMETRIC_FLEX = "peak_price_geometric_flex"
|
||||
|
||||
ATTRIBUTION = "Data provided by Tibber"
|
||||
|
||||
|
|
@ -131,6 +137,12 @@ DEFAULT_RELAXATION_ATTEMPTS_BEST = 11 # Default: 11 steps allows escalation fro
|
|||
DEFAULT_ENABLE_MIN_PERIODS_PEAK = True # Default: minimum periods feature enabled for peak price
|
||||
DEFAULT_MIN_PERIODS_PEAK = 2 # Default: require at least 2 peak price periods (when enabled)
|
||||
DEFAULT_RELAXATION_ATTEMPTS_PEAK = 11 # Default: 11 steps allows escalation from 20% to 50% (3% increment per step)
|
||||
DEFAULT_BEST_PRICE_EXTEND_TO_VERY_CHEAP = False # Default: disabled (opt-in feature)
|
||||
DEFAULT_BEST_PRICE_MAX_EXTENSION_INTERVALS = 4 # Default: up to 4 intervals (1 hour) per side
|
||||
DEFAULT_BEST_PRICE_GEOMETRIC_FLEX = 0 # Default: 0% (disabled); positive int % (e.g. 10 = 10%)
|
||||
DEFAULT_PEAK_PRICE_EXTEND_TO_VERY_EXPENSIVE = False # Default: disabled (opt-in feature)
|
||||
DEFAULT_PEAK_PRICE_MAX_EXTENSION_INTERVALS = 4 # Default: up to 4 intervals (1 hour) per side
|
||||
DEFAULT_PEAK_PRICE_GEOMETRIC_FLEX = 0 # Default: 0% (disabled); positive int % (e.g. 10 = 10%)
|
||||
|
||||
# Validation limits (used in GUI schemas and server-side validation)
|
||||
# These ensure consistency between frontend and backend validation
|
||||
|
|
@ -139,6 +151,8 @@ MAX_DISTANCE_PERCENTAGE = 50 # Maximum distance from average percentage (GUI sl
|
|||
MAX_GAP_COUNT = 8 # Maximum gap count for level filtering (GUI slider limit)
|
||||
MAX_MIN_PERIODS = 10 # Maximum number of minimum periods per day (GUI slider limit)
|
||||
MAX_RELAXATION_ATTEMPTS = 12 # Maximum relaxation attempts (GUI slider limit)
|
||||
MAX_EXTENSION_INTERVALS = 12 # Maximum extension intervals per side (GUI slider limit = 3 hours)
|
||||
MAX_GEOMETRIC_FLEX = 25 # Maximum geometric flex bonus percentage (GUI slider limit)
|
||||
MIN_PERIOD_LENGTH = 15 # Minimum period length in minutes (1 quarter hour)
|
||||
MAX_MIN_PERIOD_LENGTH = 180 # Maximum for minimum period length setting (3 hours - realistic for required minimum)
|
||||
|
||||
|
|
@ -407,6 +421,15 @@ def get_default_options(currency_code: str | None) -> dict[str, Any]:
|
|||
CONF_MIN_PERIODS_PEAK: DEFAULT_MIN_PERIODS_PEAK,
|
||||
CONF_RELAXATION_ATTEMPTS_PEAK: DEFAULT_RELAXATION_ATTEMPTS_PEAK,
|
||||
},
|
||||
# Nested section: Extension settings (shared by best/peak price)
|
||||
"extension_settings": {
|
||||
CONF_BEST_PRICE_EXTEND_TO_VERY_CHEAP: DEFAULT_BEST_PRICE_EXTEND_TO_VERY_CHEAP,
|
||||
CONF_BEST_PRICE_MAX_EXTENSION_INTERVALS: DEFAULT_BEST_PRICE_MAX_EXTENSION_INTERVALS,
|
||||
CONF_BEST_PRICE_GEOMETRIC_FLEX: DEFAULT_BEST_PRICE_GEOMETRIC_FLEX,
|
||||
CONF_PEAK_PRICE_EXTEND_TO_VERY_EXPENSIVE: DEFAULT_PEAK_PRICE_EXTEND_TO_VERY_EXPENSIVE,
|
||||
CONF_PEAK_PRICE_MAX_EXTENSION_INTERVALS: DEFAULT_PEAK_PRICE_MAX_EXTENSION_INTERVALS,
|
||||
CONF_PEAK_PRICE_GEOMETRIC_FLEX: DEFAULT_PEAK_PRICE_GEOMETRIC_FLEX,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -788,6 +788,18 @@ class TibberPricesDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
|||
else:
|
||||
# Check for repair conditions after successful update
|
||||
await self._check_repair_conditions(result, current_time)
|
||||
|
||||
# Fire event when new data was fetched from API (not cached)
|
||||
if api_called and result and "priceInfo" in result and len(result["priceInfo"]) > 0:
|
||||
self.hass.bus.async_fire(
|
||||
"tibber_prices_data_updated",
|
||||
{
|
||||
"home_id": self._home_id,
|
||||
"entry_id": self.config_entry.entry_id,
|
||||
"interval_count": len(result["priceInfo"]),
|
||||
},
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
async def _track_rate_limit_error(self, error: Exception) -> None:
|
||||
|
|
@ -863,9 +875,11 @@ class TibberPricesDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
|||
"""Get threshold percentages from config options."""
|
||||
return self._data_transformer.get_threshold_percentages()
|
||||
|
||||
def _calculate_periods_for_price_info(self, price_info: dict[str, Any]) -> dict[str, Any]:
|
||||
def _calculate_periods_for_price_info(
|
||||
self, price_info: dict[str, Any], day_patterns: dict[str, Any] | None = None
|
||||
) -> dict[str, Any]:
|
||||
"""Calculate periods (best price and peak price) for the given price info."""
|
||||
return self._period_calculator.calculate_periods_for_price_info(price_info)
|
||||
return self._period_calculator.calculate_periods_for_price_info(price_info, day_patterns)
|
||||
|
||||
def _transform_data(self, raw_data: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Transform raw data for main entry (aggregated view of all homes)."""
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import logging
|
|||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from custom_components.tibber_prices import const as _const
|
||||
from custom_components.tibber_prices.coordinator.period_handlers.day_pattern import detect_day_patterns
|
||||
from custom_components.tibber_prices.utils.price import enrich_price_info_with_differences
|
||||
|
||||
if TYPE_CHECKING:
|
||||
|
|
@ -27,7 +28,7 @@ class TibberPricesDataTransformer:
|
|||
self,
|
||||
config_entry: ConfigEntry,
|
||||
log_prefix: str,
|
||||
calculate_periods_fn: Callable[[dict[str, Any]], dict[str, Any]],
|
||||
calculate_periods_fn: Callable[[dict[str, Any], dict[str, Any] | None], dict[str, Any]],
|
||||
time: TibberPricesTimeService,
|
||||
) -> None:
|
||||
"""Initialize the data transformer."""
|
||||
|
|
@ -270,9 +271,18 @@ class TibberPricesDataTransformer:
|
|||
"currency": currency,
|
||||
}
|
||||
|
||||
# Detect day patterns (yesterday / today / tomorrow)
|
||||
# IMPORTANT: Must be computed BEFORE pricePeriods so geometric flex can use pattern data
|
||||
transformed_data["dayPatterns"] = detect_day_patterns(
|
||||
transformed_data["priceInfo"],
|
||||
time=self.time,
|
||||
)
|
||||
|
||||
# Calculate periods (best price and peak price)
|
||||
if "priceInfo" in transformed_data:
|
||||
transformed_data["pricePeriods"] = self._calculate_periods_fn(transformed_data["priceInfo"])
|
||||
transformed_data["pricePeriods"] = self._calculate_periods_fn(
|
||||
transformed_data["priceInfo"], transformed_data.get("dayPatterns")
|
||||
)
|
||||
|
||||
# Cache the transformed data
|
||||
self._cached_transformed_data = transformed_data
|
||||
|
|
|
|||
|
|
@ -19,20 +19,37 @@ from __future__ import annotations
|
|||
# Re-export main API functions
|
||||
from .core import calculate_periods
|
||||
|
||||
# Re-export day pattern detection
|
||||
from .day_pattern import detect_day_patterns
|
||||
|
||||
# Re-export outlier filtering
|
||||
from .outlier_filtering import filter_price_outliers
|
||||
|
||||
# Re-export relaxation
|
||||
from .relaxation import calculate_periods_with_relaxation
|
||||
|
||||
# Re-export shape extension
|
||||
from .shape_extension import extend_periods_for_shape
|
||||
|
||||
# Re-export constants and types
|
||||
from .types import (
|
||||
ALL_DAY_PATTERNS,
|
||||
DAY_PATTERN_DOUBLE_PEAK,
|
||||
DAY_PATTERN_DOUBLE_VALLEY,
|
||||
DAY_PATTERN_FALLING,
|
||||
DAY_PATTERN_FLAT,
|
||||
DAY_PATTERN_MIXED,
|
||||
DAY_PATTERN_PEAK,
|
||||
DAY_PATTERN_RISING,
|
||||
DAY_PATTERN_VALLEY,
|
||||
INDENT_L0,
|
||||
INDENT_L1,
|
||||
INDENT_L2,
|
||||
INDENT_L3,
|
||||
INDENT_L4,
|
||||
INDENT_L5,
|
||||
DayPatternDict,
|
||||
SegmentDict,
|
||||
TibberPricesIntervalCriteria,
|
||||
TibberPricesPeriodConfig,
|
||||
TibberPricesPeriodData,
|
||||
|
|
@ -41,12 +58,23 @@ from .types import (
|
|||
)
|
||||
|
||||
__all__ = [
|
||||
"ALL_DAY_PATTERNS",
|
||||
"DAY_PATTERN_DOUBLE_PEAK",
|
||||
"DAY_PATTERN_DOUBLE_VALLEY",
|
||||
"DAY_PATTERN_FALLING",
|
||||
"DAY_PATTERN_FLAT",
|
||||
"DAY_PATTERN_MIXED",
|
||||
"DAY_PATTERN_PEAK",
|
||||
"DAY_PATTERN_RISING",
|
||||
"DAY_PATTERN_VALLEY",
|
||||
"INDENT_L0",
|
||||
"INDENT_L1",
|
||||
"INDENT_L2",
|
||||
"INDENT_L3",
|
||||
"INDENT_L4",
|
||||
"INDENT_L5",
|
||||
"DayPatternDict",
|
||||
"SegmentDict",
|
||||
"TibberPricesIntervalCriteria",
|
||||
"TibberPricesPeriodConfig",
|
||||
"TibberPricesPeriodData",
|
||||
|
|
@ -54,5 +82,7 @@ __all__ = [
|
|||
"TibberPricesThresholdConfig",
|
||||
"calculate_periods",
|
||||
"calculate_periods_with_relaxation",
|
||||
"detect_day_patterns",
|
||||
"extend_periods_for_shape",
|
||||
"filter_price_outliers",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ from .period_building import (
|
|||
from .period_statistics import (
|
||||
extract_period_summaries,
|
||||
)
|
||||
from .shape_extension import extend_periods_for_shape
|
||||
from .types import TibberPricesThresholdConfig
|
||||
|
||||
# Flex limits to prevent degenerate behavior (see docs/development/period-calculation-theory.md)
|
||||
|
|
@ -37,6 +38,7 @@ def calculate_periods(
|
|||
*,
|
||||
config: TibberPricesPeriodConfig,
|
||||
time: TibberPricesTimeService,
|
||||
day_patterns_by_date: dict | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Calculate price periods (best or peak) from price data.
|
||||
|
|
@ -58,6 +60,7 @@ def calculate_periods(
|
|||
config: Period configuration containing reverse_sort, flex, min_distance_from_avg,
|
||||
min_period_length, threshold_low, and threshold_high.
|
||||
time: TibberPricesTimeService instance (required).
|
||||
day_patterns_by_date: Optional dict mapping date → day pattern dict for geometric flex bonus.
|
||||
|
||||
Returns:
|
||||
Dict with:
|
||||
|
|
@ -155,6 +158,8 @@ def calculate_periods(
|
|||
"intervals_by_day": intervals_by_day, # Needed for day volatility calculation
|
||||
"flex": flex,
|
||||
"min_distance_from_avg": min_distance_from_avg,
|
||||
"geometric_extra_flex": config.geometric_extra_flex, # Extra flex for geometric zone
|
||||
"day_patterns_by_date": day_patterns_by_date, # Pattern data keyed by date (may be None)
|
||||
}
|
||||
raw_periods = build_periods(
|
||||
all_prices_smoothed, # Use smoothed prices for period formation
|
||||
|
|
@ -209,6 +214,20 @@ def calculate_periods(
|
|||
time=time,
|
||||
)
|
||||
|
||||
# Step 7.5: Extend periods into adjacent VERY_CHEAP / VERY_EXPENSIVE intervals
|
||||
# This is an opt-in feature (disabled by default) that adds contiguous
|
||||
# extreme-level intervals on each side of an already-found period.
|
||||
if config.extend_to_extreme and config.max_extension_intervals > 0:
|
||||
period_summaries = extend_periods_for_shape(
|
||||
period_summaries,
|
||||
all_prices_sorted,
|
||||
price_context,
|
||||
reverse_sort=reverse_sort,
|
||||
max_extension_intervals=config.max_extension_intervals,
|
||||
thresholds=thresholds,
|
||||
time=time,
|
||||
)
|
||||
|
||||
# Step 8: Cross-day extension for late-night periods
|
||||
# If a best-price period ends near midnight and tomorrow has continued low prices,
|
||||
# extend the period across midnight to give users the full cheap window
|
||||
|
|
|
|||
|
|
@ -0,0 +1,577 @@
|
|||
"""
|
||||
Day price pattern detection for Tibber Prices.
|
||||
|
||||
Analyses quarter-hourly price intervals for a calendar day and classifies them
|
||||
into a small set of patterns that are meaningful for switching decisions:
|
||||
|
||||
VALLEY - Single price minimum (U/V-shape, cheap middle)
|
||||
PEAK - Single price maximum (Lambda-shape, expensive middle)
|
||||
DOUBLE_VALLEY - Two minima separated by a peak (W-shape)
|
||||
DOUBLE_PEAK - Two peaks separated by a valley (M-shape)
|
||||
FLAT - No significant variation (CV <= 10 %)
|
||||
RISING - Monotonically / persistently rising
|
||||
FALLING - Monotonically / persistently falling
|
||||
MIXED - Multiple extrema that do not neatly fit above patterns
|
||||
|
||||
For VALLEY and PEAK the module also locates the *knee points* (left and right
|
||||
inflection points of the flanks) using a simplified Kneedle algorithm so that
|
||||
Phases 3+ can extend period boundaries geometrically.
|
||||
|
||||
Intra-day segments are surfaced as a list of consecutive region dicts, allowing
|
||||
automations to query "is the current hour in a rising segment?".
|
||||
|
||||
All functions are pure (no side effects) and operate on already-enriched
|
||||
interval dicts produced by utils/price.py.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import math
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from datetime import date, datetime
|
||||
|
||||
from custom_components.tibber_prices.coordinator.time_service import TibberPricesTimeService
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
_LOGGER_DETAILS = logging.getLogger(__name__ + ".details")
|
||||
|
||||
# ─── constants ────────────────────────────────────────────────────────────────
|
||||
|
||||
# A day is considered "flat" if its coefficient of variation is below this value.
|
||||
# Reuses the same threshold as relaxation.py (LOW_CV_FLAT_DAY_THRESHOLD = 10.0).
|
||||
FLAT_CV_THRESHOLD = 10.0 # %
|
||||
|
||||
# Minimum amplitude an extremum must have to count as "significant".
|
||||
# Defined as a fraction of the day's price span. 0.20 = 20 % of span.
|
||||
MIN_EXTREMUM_AMPLITUDE_RATIO = 0.20
|
||||
|
||||
# Smoothing window (in 15-min intervals) for the rolling-average pre-filter.
|
||||
SMOOTH_WINDOW = 4 # 4 x 15 min = 1 h
|
||||
|
||||
# Minimum intervals in a day to attempt pattern detection.
|
||||
MIN_DAY_INTERVALS = 4
|
||||
|
||||
# Minimum intervals in a series to search for extrema.
|
||||
MIN_EXTREMA_INTERVALS = 3
|
||||
|
||||
# Edge zone: relative position threshold for RISING / FALLING detection.
|
||||
_EDGE_ZONE = 0.25
|
||||
|
||||
# Pattern string constants
|
||||
DAY_PATTERN_VALLEY = "valley"
|
||||
DAY_PATTERN_PEAK = "peak"
|
||||
DAY_PATTERN_DOUBLE_VALLEY = "double_valley"
|
||||
DAY_PATTERN_DOUBLE_PEAK = "double_peak"
|
||||
DAY_PATTERN_FLAT = "flat"
|
||||
DAY_PATTERN_RISING = "rising"
|
||||
DAY_PATTERN_FALLING = "falling"
|
||||
DAY_PATTERN_MIXED = "mixed"
|
||||
|
||||
# Segment type constants
|
||||
SEGMENT_TYPE_RISING = "rising"
|
||||
SEGMENT_TYPE_FALLING = "falling"
|
||||
SEGMENT_TYPE_FLAT = "flat"
|
||||
|
||||
|
||||
# ─── public API ───────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def detect_day_patterns(
|
||||
all_prices: list[dict[str, Any]],
|
||||
*,
|
||||
time: TibberPricesTimeService,
|
||||
) -> dict[str, dict[str, Any]]:
|
||||
"""
|
||||
Detect price patterns for yesterday, today, and tomorrow.
|
||||
|
||||
Groups enriched price intervals by calendar day and runs pattern detection
|
||||
on each. Always returns all three keys; ``tomorrow`` may be ``None`` if
|
||||
data is not yet available.
|
||||
|
||||
Args:
|
||||
all_prices: Flat list of enriched price interval dicts (the same list
|
||||
that ``coordinator.data["priceInfo"]`` holds).
|
||||
time: TibberPricesTimeService (needed for timezone-aware date boundaries).
|
||||
|
||||
Returns:
|
||||
``{"yesterday": <dict|None>, "today": <dict|None>, "tomorrow": <dict|None>}``
|
||||
where each value is a ``DayPatternDict`` (see _detect_single_day_pattern).
|
||||
|
||||
"""
|
||||
# ── group intervals by calendar day ────────────────────────────────────────
|
||||
from .period_building import split_intervals_by_day # avoid circular at import time # noqa: PLC0415
|
||||
|
||||
intervals_by_day, _ = split_intervals_by_day(all_prices, time=time)
|
||||
|
||||
now = time.now()
|
||||
today_date: date = now.date()
|
||||
|
||||
import datetime as _dt # noqa: PLC0415
|
||||
|
||||
yesterday_date = today_date - _dt.timedelta(days=1)
|
||||
tomorrow_date = today_date + _dt.timedelta(days=1)
|
||||
|
||||
result: dict[str, dict[str, Any] | None] = {
|
||||
"yesterday": None,
|
||||
"today": None,
|
||||
"tomorrow": None,
|
||||
}
|
||||
|
||||
day_map: dict[str, date] = {
|
||||
"yesterday": yesterday_date,
|
||||
"today": today_date,
|
||||
"tomorrow": tomorrow_date,
|
||||
}
|
||||
|
||||
for label, date_key in day_map.items():
|
||||
intervals = intervals_by_day.get(date_key)
|
||||
if intervals and len(intervals) >= MIN_DAY_INTERVALS:
|
||||
try:
|
||||
result[label] = _detect_single_day_pattern(intervals, time=time)
|
||||
except Exception:
|
||||
_LOGGER.exception("Day pattern detection failed for %s (%s)", label, date_key)
|
||||
result[label] = None
|
||||
else:
|
||||
result[label] = None
|
||||
|
||||
return result # type: ignore[return-value]
|
||||
|
||||
|
||||
# ─── single-day detection ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _detect_single_day_pattern(
|
||||
intervals: list[dict[str, Any]],
|
||||
*,
|
||||
time: TibberPricesTimeService,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Analyse a single day's intervals and return a DayPatternDict.
|
||||
|
||||
The returned dict has the shape described in AGENTS.md (DayPatternDict).
|
||||
"""
|
||||
# Extract prices and datetimes (already tz-aware from enrichment)
|
||||
prices_raw: list[float] = [float(iv["total"]) for iv in intervals]
|
||||
times: list[datetime] = [time.get_interval_time(iv) for iv in intervals] # type: ignore[misc]
|
||||
|
||||
# ── coefficient of variation ────────────────────────────────────────────────
|
||||
n = len(prices_raw)
|
||||
mean_price = sum(prices_raw) / n
|
||||
variance = sum((p - mean_price) ** 2 for p in prices_raw) / n
|
||||
std_dev = math.sqrt(variance)
|
||||
cv_pct = round((std_dev / abs(mean_price)) * 100, 1) if mean_price != 0 else 0.0
|
||||
|
||||
# ── smooth prices (1-h rolling average) ────────────────────────────────────
|
||||
smoothed = _smooth_prices(prices_raw, window=SMOOTH_WINDOW)
|
||||
|
||||
# ── find significant extrema ────────────────────────────────────────────────
|
||||
price_span = max(prices_raw) - min(prices_raw) if prices_raw else 0.0
|
||||
extrema = _find_significant_extrema(smoothed, min_amplitude=price_span * MIN_EXTREMUM_AMPLITUDE_RATIO)
|
||||
|
||||
# ── classify pattern ────────────────────────────────────────────────────────
|
||||
pattern, confidence = _classify_pattern(extrema, cv_pct, times)
|
||||
|
||||
# ── knee points + primary extreme time ─────────────────────────────────────
|
||||
extreme_time: datetime | None = None
|
||||
valley_start: datetime | None = None
|
||||
valley_end: datetime | None = None
|
||||
peak_start: datetime | None = None
|
||||
peak_end: datetime | None = None
|
||||
|
||||
if pattern == DAY_PATTERN_VALLEY:
|
||||
# Primary extreme = global minimum
|
||||
min_idx = prices_raw.index(min(prices_raw))
|
||||
extreme_time = times[min_idx] if min_idx < len(times) else None
|
||||
lk, rk = _find_knee_points(smoothed, min_idx)
|
||||
valley_start = times[lk] if lk is not None and lk < len(times) else None
|
||||
valley_end = times[rk] if rk is not None and rk < len(times) else None
|
||||
|
||||
elif pattern == DAY_PATTERN_PEAK:
|
||||
max_idx = prices_raw.index(max(prices_raw))
|
||||
extreme_time = times[max_idx] if max_idx < len(times) else None
|
||||
lk, rk = _find_knee_points(smoothed, max_idx)
|
||||
peak_start = times[lk] if lk is not None and lk < len(times) else None
|
||||
peak_end = times[rk] if rk is not None and rk < len(times) else None
|
||||
|
||||
elif pattern == DAY_PATTERN_DOUBLE_VALLEY and extrema:
|
||||
# Primary extreme = deeper of the two minima
|
||||
min_extrema = [e for e in extrema if e["type"] == "min"]
|
||||
if min_extrema:
|
||||
primary = min(min_extrema, key=lambda e: e["price"])
|
||||
extreme_time = times[primary["idx"]] if primary["idx"] < len(times) else None
|
||||
|
||||
elif pattern == DAY_PATTERN_DOUBLE_PEAK and extrema:
|
||||
max_extrema = [e for e in extrema if e["type"] == "max"]
|
||||
if max_extrema:
|
||||
primary = max(max_extrema, key=lambda e: e["price"])
|
||||
extreme_time = times[primary["idx"]] if primary["idx"] < len(times) else None
|
||||
|
||||
# ── intra-day segments ──────────────────────────────────────────────────────
|
||||
segments = _detect_segments(extrema, prices_raw, times)
|
||||
|
||||
result: dict[str, Any] = {
|
||||
"pattern": pattern,
|
||||
"confidence": round(confidence, 3),
|
||||
"day_cv_percent": cv_pct,
|
||||
"segments": segments,
|
||||
"extreme_time": extreme_time,
|
||||
"valley_start": valley_start,
|
||||
"valley_end": valley_end,
|
||||
"peak_start": peak_start,
|
||||
"peak_end": peak_end,
|
||||
}
|
||||
|
||||
_LOGGER_DETAILS.debug(
|
||||
" Day pattern: %s (confidence=%.2f, cv=%.1f%%, extrema=%d, segments=%d)",
|
||||
pattern,
|
||||
confidence,
|
||||
cv_pct,
|
||||
len(extrema),
|
||||
len(segments),
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
# ─── smoothing ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _smooth_prices(prices: list[float], window: int = SMOOTH_WINDOW) -> list[float]:
|
||||
"""
|
||||
Apply a centred rolling-average with the given window width.
|
||||
|
||||
Edge intervals use a narrower window (no zero-padding) so that pattern
|
||||
detection at the start/end of the day is not distorted.
|
||||
"""
|
||||
n = len(prices)
|
||||
half = window // 2
|
||||
smoothed: list[float] = []
|
||||
for i in range(n):
|
||||
lo = max(0, i - half)
|
||||
hi = min(n, i + half + 1)
|
||||
smoothed.append(sum(prices[lo:hi]) / (hi - lo))
|
||||
return smoothed
|
||||
|
||||
|
||||
# ─── extrema detection ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _find_significant_extrema(
|
||||
smoothed: list[float],
|
||||
*,
|
||||
min_amplitude: float,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""
|
||||
Find local minima and maxima in the smoothed price series.
|
||||
|
||||
A local extremum is retained only if it exceeds *min_amplitude* above/below
|
||||
both of its closest neighbours of the opposite polarity (prominence filter).
|
||||
|
||||
Returns a list of ``{"idx": int, "type": "min"|"max", "price": float}``
|
||||
entries sorted by index.
|
||||
"""
|
||||
n = len(smoothed)
|
||||
if n < MIN_EXTREMA_INTERVALS:
|
||||
return []
|
||||
|
||||
# ── raw local extrema (strict local min/max) ────────────────────────────────
|
||||
candidates: list[dict[str, Any]] = []
|
||||
for i in range(1, n - 1):
|
||||
prev_p = smoothed[i - 1]
|
||||
cur_p = smoothed[i]
|
||||
next_p = smoothed[i + 1]
|
||||
if cur_p <= prev_p and cur_p <= next_p and cur_p < smoothed[0] and cur_p < smoothed[-1]:
|
||||
candidates.append({"idx": i, "type": "min", "price": cur_p})
|
||||
elif cur_p >= prev_p and cur_p >= next_p and cur_p > smoothed[0] and cur_p > smoothed[-1]:
|
||||
candidates.append({"idx": i, "type": "max", "price": cur_p})
|
||||
|
||||
if not candidates:
|
||||
return []
|
||||
|
||||
# ── amplitude filter ────────────────────────────────────────────────────────
|
||||
# For each candidate, compute prominence = distance to the nearest extremum
|
||||
# of opposite type (or the global opposite extreme if none exist).
|
||||
# We use a simpler heuristic: compare against the mean of its two flanking
|
||||
# values in the smoothed series (one window radius on each side).
|
||||
significant: list[dict[str, Any]] = []
|
||||
for cand in candidates:
|
||||
idx = cand["idx"]
|
||||
hw = max(4, n // 8) # neighbourhood half-width: ≥4 intervals, up to 1/8 of day
|
||||
lo = max(0, idx - hw)
|
||||
hi = min(n, idx + hw + 1)
|
||||
neighbourhood = smoothed[lo:hi]
|
||||
if cand["type"] == "min":
|
||||
reference = sum(neighbourhood) / len(neighbourhood)
|
||||
prominence = reference - cand["price"]
|
||||
else:
|
||||
reference = sum(neighbourhood) / len(neighbourhood)
|
||||
prominence = cand["price"] - reference
|
||||
if prominence >= min_amplitude * 0.8: # slight tolerance on the threshold
|
||||
significant.append(cand)
|
||||
|
||||
# ── deduplicate: keep only the most extreme value between alternating types ──
|
||||
return _deduplicate_extrema(significant)
|
||||
|
||||
|
||||
def _deduplicate_extrema(extrema: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""
|
||||
Ensure extrema alternate between min and max.
|
||||
|
||||
Between two consecutive minima (or two consecutive maxima), keep only the
|
||||
more extreme one. This mirrors the classical definition of alternating
|
||||
local extrema.
|
||||
"""
|
||||
if not extrema:
|
||||
return []
|
||||
result: list[dict[str, Any]] = [extrema[0]]
|
||||
for e in extrema[1:]:
|
||||
last = result[-1]
|
||||
if e["type"] == last["type"]:
|
||||
# Same type - keep the more extreme one
|
||||
if e["type"] == "min":
|
||||
if e["price"] < last["price"]:
|
||||
result[-1] = e
|
||||
elif e["price"] > last["price"]:
|
||||
result[-1] = e
|
||||
else:
|
||||
result.append(e)
|
||||
return result
|
||||
|
||||
|
||||
# ─── pattern classification ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _classify_pattern( # noqa: PLR0911, PLR0912
|
||||
extrema: list[dict[str, Any]],
|
||||
cv_pct: float,
|
||||
times: list[datetime],
|
||||
) -> tuple[str, float]:
|
||||
"""
|
||||
Classify the day into a pattern string and confidence score (0-1).
|
||||
|
||||
Args:
|
||||
extrema: List of significant extrema (already deduplicated).
|
||||
cv_pct: Coefficient of variation for the day (%).
|
||||
times: Timestamps of all intervals (for position calculations).
|
||||
|
||||
Returns:
|
||||
(pattern_string, confidence_float)
|
||||
|
||||
"""
|
||||
n_times = len(times)
|
||||
|
||||
# ── flat day ────────────────────────────────────────────────────────────────
|
||||
if cv_pct <= FLAT_CV_THRESHOLD:
|
||||
# Confidence scales with how flat it is relative to threshold
|
||||
confidence = max(0.5, 1.0 - cv_pct / FLAT_CV_THRESHOLD)
|
||||
return DAY_PATTERN_FLAT, confidence
|
||||
|
||||
# ── no significant extrema → monotone (rising or falling) ──────────────────
|
||||
if not extrema:
|
||||
# Cannot determine direction without access to underlying prices from here.
|
||||
# The caller (_detect_single_day_pattern) handles the RISING/FALLING case
|
||||
# before calling _classify_pattern when there are no extrema but prices exist.
|
||||
return DAY_PATTERN_MIXED, 0.4
|
||||
|
||||
n_extrema = len(extrema)
|
||||
types = [e["type"] for e in extrema]
|
||||
|
||||
# ── single extremum ─────────────────────────────────────────────────────────
|
||||
if n_extrema == 1:
|
||||
e = extrema[0]
|
||||
# Check position: central extrema → stronger pattern
|
||||
rel_pos = e["idx"] / max(1, n_times - 1)
|
||||
centrality = 1.0 - abs(rel_pos - 0.5) * 2 # 0 at edges, 1 at centre
|
||||
|
||||
if e["type"] == "min":
|
||||
confidence = 0.6 + 0.4 * centrality
|
||||
return DAY_PATTERN_VALLEY, confidence
|
||||
# max
|
||||
# Check if it's edge-dominant: peak near start -> FALLING, near end -> RISING
|
||||
if rel_pos < _EDGE_ZONE:
|
||||
return DAY_PATTERN_FALLING, 0.6
|
||||
if rel_pos > 1.0 - _EDGE_ZONE:
|
||||
return DAY_PATTERN_RISING, 0.6
|
||||
confidence = 0.6 + 0.4 * centrality
|
||||
return DAY_PATTERN_PEAK, confidence
|
||||
|
||||
# ── two extrema ─────────────────────────────────────────────────────────────
|
||||
if n_extrema == 2: # noqa: PLR2004
|
||||
if types == ["max", "min"]:
|
||||
return DAY_PATTERN_FALLING, 0.7
|
||||
if types == ["min", "max"]:
|
||||
return DAY_PATTERN_RISING, 0.7
|
||||
if types == ["min", "min"]:
|
||||
return DAY_PATTERN_DOUBLE_VALLEY, 0.65
|
||||
if types == ["max", "max"]:
|
||||
return DAY_PATTERN_DOUBLE_PEAK, 0.65
|
||||
|
||||
# ── three extrema ────────────────────────────────────────────────────────────
|
||||
if n_extrema == 3: # noqa: PLR2004
|
||||
# min-max-min → W-shape
|
||||
if types == ["min", "max", "min"]:
|
||||
return DAY_PATTERN_DOUBLE_VALLEY, 0.75
|
||||
# max-min-max → M-shape
|
||||
if types == ["max", "min", "max"]:
|
||||
return DAY_PATTERN_DOUBLE_PEAK, 0.75
|
||||
# min-max or max-min with trailing → RISING/FALLING with extra bump
|
||||
if types[0] == "min" and types[-1] == "max":
|
||||
return DAY_PATTERN_RISING, 0.55
|
||||
if types[0] == "max" and types[-1] == "min":
|
||||
return DAY_PATTERN_FALLING, 0.55
|
||||
|
||||
# ── four or more extrema ─────────────────────────────────────────────────────
|
||||
# Count dominating type
|
||||
n_min = types.count("min")
|
||||
n_max = types.count("max")
|
||||
if abs(n_min - n_max) <= 1:
|
||||
return DAY_PATTERN_MIXED, 0.5
|
||||
# More minima: day is mostly cheap → loosely valley-ish
|
||||
if n_min > n_max:
|
||||
return DAY_PATTERN_MIXED, 0.45
|
||||
return DAY_PATTERN_MIXED, 0.45
|
||||
|
||||
|
||||
# ─── knee point detection (simplified Kneedle) ───────────────────────────────
|
||||
|
||||
|
||||
def _find_knee_points(
|
||||
smoothed: list[float],
|
||||
extreme_idx: int,
|
||||
) -> tuple[int | None, int | None]:
|
||||
"""
|
||||
Find the left and right knee points of a V-/Λ-shaped flank.
|
||||
|
||||
Uses a simplified Kneedle algorithm:
|
||||
1. Normalise each flank to [0,1] on both axes.
|
||||
2. Compute the perpendicular distance of each point from the straight line
|
||||
connecting the flank start to the extreme point.
|
||||
3. The knee is the point of maximum perpendicular distance.
|
||||
|
||||
Args:
|
||||
smoothed: Smoothed price series for the full day.
|
||||
extreme_idx: Index of the valley minimum (VALLEY) or peak maximum (PEAK).
|
||||
is_minimum: True for valley (prices falling then rising),
|
||||
False for peak (prices rising then falling).
|
||||
|
||||
Returns:
|
||||
``(left_knee_idx, right_knee_idx)`` - indices into ``smoothed``.
|
||||
Either may be ``None`` if the flank is too short.
|
||||
|
||||
"""
|
||||
n = len(smoothed)
|
||||
|
||||
left_idx = _find_knee_on_flank(smoothed, start=0, end=extreme_idx)
|
||||
right_idx = _find_knee_on_flank(smoothed, start=extreme_idx, end=n - 1)
|
||||
|
||||
return left_idx, right_idx
|
||||
|
||||
|
||||
def _find_knee_on_flank(
|
||||
prices: list[float],
|
||||
start: int,
|
||||
end: int,
|
||||
) -> int | None:
|
||||
"""
|
||||
Locate the knee on one flank using the simplified Kneedle method.
|
||||
|
||||
Args:
|
||||
prices: Full price series.
|
||||
start: Index of flank start.
|
||||
end: Index of flank end (the extreme point).
|
||||
descending: True if prices fall from start → end, False if they rise.
|
||||
|
||||
Returns:
|
||||
Index of knee point, or ``None`` if flank is fewer than 4 intervals.
|
||||
|
||||
"""
|
||||
length = end - start
|
||||
if length < MIN_EXTREMA_INTERVALS:
|
||||
return None
|
||||
|
||||
p_start = prices[start]
|
||||
p_end = prices[end]
|
||||
|
||||
# Normalise so that start=(0,0) and end=(1,1)
|
||||
px_range = float(length)
|
||||
py_range = p_end - p_start
|
||||
if abs(py_range) < 1e-9: # noqa: PLR2004
|
||||
return None # Flat flank - no knee
|
||||
|
||||
max_dist = 0.0
|
||||
knee_idx: int | None = None
|
||||
for i in range(start + 1, end):
|
||||
# Normalised coordinates
|
||||
nx = (i - start) / px_range
|
||||
ny = (prices[i] - p_start) / py_range
|
||||
# For the line y=x: perpendicular distance = |ny - nx| / sqrt(2)
|
||||
dist = abs(ny - nx) / math.sqrt(2)
|
||||
if dist > max_dist:
|
||||
max_dist = dist
|
||||
knee_idx = i
|
||||
|
||||
return knee_idx
|
||||
|
||||
|
||||
# ─── intra-day segment detection ─────────────────────────────────────────────
|
||||
|
||||
|
||||
def _detect_segments(
|
||||
extrema: list[dict[str, Any]],
|
||||
prices: list[float],
|
||||
times: list[datetime],
|
||||
) -> list[dict[str, Any]]:
|
||||
"""
|
||||
Build a list of monotone segments separated by the detected extrema.
|
||||
|
||||
Each segment is a dict with:
|
||||
type - "rising" | "falling" | "flat"
|
||||
start - tz-aware datetime of first interval
|
||||
end - tz-aware datetime of last interval
|
||||
price_min - min price in segment (EUR/NOK/SEK)
|
||||
price_max - max price in segment
|
||||
price_mean - mean price in segment
|
||||
|
||||
"""
|
||||
n = len(prices)
|
||||
if n == 0:
|
||||
return []
|
||||
|
||||
# Build boundary indices: 0, all extremum indices, n-1
|
||||
boundaries = [0, *sorted(e["idx"] for e in extrema), n - 1]
|
||||
# Deduplicate consecutive boundaries
|
||||
boundaries = list(dict.fromkeys(boundaries)) # preserves order, removes dupes
|
||||
|
||||
segments: list[dict[str, Any]] = []
|
||||
for seg_i in range(len(boundaries) - 1):
|
||||
lo = boundaries[seg_i]
|
||||
hi = boundaries[seg_i + 1]
|
||||
if hi <= lo:
|
||||
continue
|
||||
seg_prices = prices[lo : hi + 1]
|
||||
price_start = prices[lo]
|
||||
price_end = prices[hi]
|
||||
delta = price_end - price_start
|
||||
span = max(seg_prices) - min(seg_prices)
|
||||
|
||||
if span < (max(prices) - min(prices)) * 0.05:
|
||||
seg_type = SEGMENT_TYPE_FLAT
|
||||
elif delta > 0:
|
||||
seg_type = SEGMENT_TYPE_RISING
|
||||
else:
|
||||
seg_type = SEGMENT_TYPE_FALLING
|
||||
|
||||
seg: dict[str, Any] = {
|
||||
"type": seg_type,
|
||||
"start": times[lo].isoformat() if lo < len(times) and times[lo] is not None else None,
|
||||
"end": times[hi].isoformat() if hi < len(times) and times[hi] is not None else None,
|
||||
"price_min": round(min(seg_prices), 4),
|
||||
"price_max": round(max(seg_prices), 4),
|
||||
"price_mean": round(sum(seg_prices) / len(seg_prices), 4),
|
||||
}
|
||||
segments.append(seg)
|
||||
|
||||
return segments
|
||||
|
|
@ -11,9 +11,11 @@ See docs/development/period-calculation-theory.md for detailed explanation.
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from datetime import datetime
|
||||
|
||||
from .types import TibberPricesIntervalCriteria
|
||||
|
||||
from custom_components.tibber_prices.const import PRICE_LEVEL_MAPPING
|
||||
|
|
@ -241,3 +243,54 @@ def check_interval_criteria(
|
|||
meets_min_distance = price <= min_distance_threshold
|
||||
|
||||
return in_flex, meets_min_distance
|
||||
|
||||
|
||||
def compute_geometric_flex_bonus(
|
||||
interval_time: datetime,
|
||||
day_pattern: dict[str, Any] | None,
|
||||
*,
|
||||
extra_flex: float,
|
||||
reverse_sort: bool,
|
||||
) -> float:
|
||||
"""
|
||||
Return extra flex if interval falls within the valley/peak geometric zone.
|
||||
|
||||
For best price (reverse_sort=False): widens flex inside the VALLEY zone
|
||||
defined by [valley_start, valley_end] knee points.
|
||||
For peak price (reverse_sort=True): widens flex inside the PEAK zone
|
||||
defined by [peak_start, peak_end] knee points.
|
||||
|
||||
Args:
|
||||
interval_time: Timezone-aware datetime of the interval's start.
|
||||
day_pattern: DayPatternDict for the interval's calendar day, or None.
|
||||
extra_flex: Additional flex to add (decimal, e.g. 0.10 for 10%).
|
||||
reverse_sort: True for peak price, False for best price.
|
||||
|
||||
Returns:
|
||||
``extra_flex`` if the interval is inside the geometric zone, else ``0.0``.
|
||||
|
||||
"""
|
||||
if not day_pattern or extra_flex <= 0:
|
||||
return 0.0
|
||||
|
||||
pattern = day_pattern.get("pattern", "")
|
||||
|
||||
if reverse_sort:
|
||||
# Peak price: expand inside PEAK (Λ-shape) zone
|
||||
if pattern != "peak":
|
||||
return 0.0
|
||||
zone_start = day_pattern.get("peak_start")
|
||||
zone_end = day_pattern.get("peak_end")
|
||||
else:
|
||||
# Best price: expand inside VALLEY (V/U-shape) zone
|
||||
if pattern != "valley":
|
||||
return 0.0
|
||||
zone_start = day_pattern.get("valley_start")
|
||||
zone_end = day_pattern.get("valley_end")
|
||||
|
||||
if zone_start is None or zone_end is None:
|
||||
return 0.0
|
||||
|
||||
if zone_start <= interval_time <= zone_end:
|
||||
return extra_flex
|
||||
return 0.0
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ if TYPE_CHECKING:
|
|||
from .level_filtering import (
|
||||
apply_level_filter,
|
||||
check_interval_criteria,
|
||||
compute_geometric_flex_bonus,
|
||||
)
|
||||
from .types import TibberPricesIntervalCriteria
|
||||
|
||||
|
|
@ -83,6 +84,8 @@ def build_periods( # noqa: PLR0913, PLR0915, PLR0912 - Complex period building
|
|||
avg_prices = price_context["avg_prices"]
|
||||
flex = price_context["flex"]
|
||||
min_distance_from_avg = price_context["min_distance_from_avg"]
|
||||
geometric_extra_flex: float = float(price_context.get("geometric_extra_flex", 0.0))
|
||||
day_patterns_by_date: dict[date, dict[str, Any]] | None = price_context.get("day_patterns_by_date")
|
||||
|
||||
# Calculate level_order if level_filter is active
|
||||
level_order = None
|
||||
|
|
@ -147,7 +150,20 @@ def build_periods( # noqa: PLR0913, PLR0915, PLR0912 - Complex period building
|
|||
|
||||
# Check flex and minimum distance criteria (using smoothed price and interval's own day reference)
|
||||
criteria = criteria_by_day[ref_date]
|
||||
in_flex, meets_min_distance = check_interval_criteria(price_for_criteria, criteria)
|
||||
|
||||
# Compute geometric flex bonus if pattern-aware expansion is enabled
|
||||
geo_bonus = 0.0
|
||||
if geometric_extra_flex > 0 and day_patterns_by_date is not None:
|
||||
day_pattern_for_date = day_patterns_by_date.get(ref_date)
|
||||
geo_bonus = compute_geometric_flex_bonus(
|
||||
starts_at,
|
||||
day_pattern_for_date,
|
||||
extra_flex=geometric_extra_flex,
|
||||
reverse_sort=reverse_sort,
|
||||
)
|
||||
|
||||
effective_criteria = criteria._replace(flex=criteria.flex + geo_bonus) if geo_bonus > 0 else criteria
|
||||
in_flex, meets_min_distance = check_interval_criteria(price_for_criteria, effective_criteria)
|
||||
|
||||
# Track why intervals are filtered
|
||||
if not in_flex:
|
||||
|
|
@ -159,7 +175,7 @@ def build_periods( # noqa: PLR0913, PLR0915, PLR0912 - Complex period building
|
|||
smoothing_was_impactful = False
|
||||
if price_data.get("_smoothed", False):
|
||||
# Check if original price would have passed the same criteria
|
||||
in_flex_original, meets_min_distance_original = check_interval_criteria(price_original, criteria)
|
||||
in_flex_original, meets_min_distance_original = check_interval_criteria(price_original, effective_criteria)
|
||||
# Smoothing was impactful if original would have failed but smoothed passed
|
||||
smoothing_was_impactful = (in_flex and meets_min_distance) and not (
|
||||
in_flex_original and meets_min_distance_original
|
||||
|
|
@ -184,6 +200,7 @@ def build_periods( # noqa: PLR0913, PLR0915, PLR0912 - Complex period building
|
|||
# Only True if smoothing changed whether the interval qualified for period inclusion
|
||||
"smoothing_was_impactful": smoothing_was_impactful,
|
||||
"is_level_gap": is_level_gap, # Track if kept due to level gap tolerance
|
||||
"geometric_bonus_applied": geo_bonus > 0, # True if interval is in geometric zone
|
||||
}
|
||||
)
|
||||
elif current_period:
|
||||
|
|
|
|||
|
|
@ -220,6 +220,17 @@ def build_period_summary_dict(
|
|||
return summary
|
||||
|
||||
|
||||
def _add_interval_flag_counts(summary: dict, period: list[dict]) -> None:
|
||||
"""Add optional interval flag counts to period summary."""
|
||||
if (count := sum(1 for i in period if i.get("smoothing_was_impactful", False))) > 0:
|
||||
summary["period_interval_smoothed_count"] = count
|
||||
if (count := sum(1 for i in period if i.get("is_level_gap", False))) > 0:
|
||||
summary["period_interval_level_gap_count"] = count
|
||||
if (count := sum(1 for i in period if i.get("geometric_bonus_applied", False))) > 0:
|
||||
summary["geometric_extension_active"] = True
|
||||
summary["geometric_extension_intervals"] = count
|
||||
|
||||
|
||||
def extract_period_summaries(
|
||||
periods: list[list[dict]],
|
||||
all_prices: list[dict],
|
||||
|
|
@ -328,12 +339,6 @@ def extract_period_summaries(
|
|||
).lower()
|
||||
rating_difference_pct = calculate_aggregated_rating_difference(period_price_data)
|
||||
|
||||
# Count how many intervals in this period benefited from smoothing (i.e., would have been excluded)
|
||||
smoothed_impactful_count = sum(1 for interval in period if interval.get("smoothing_was_impactful", False))
|
||||
|
||||
# Count how many intervals were kept due to level filter gap tolerance
|
||||
level_gap_count = sum(1 for interval in period if interval.get("is_level_gap", False))
|
||||
|
||||
# Build period data and statistics objects
|
||||
period_data = TibberPricesPeriodData(
|
||||
start_time=start_time,
|
||||
|
|
@ -363,13 +368,8 @@ def extract_period_summaries(
|
|||
period_data, stats, reverse_sort=thresholds.reverse_sort, price_context=price_context
|
||||
)
|
||||
|
||||
# Add smoothing information if any intervals benefited from smoothing
|
||||
if smoothed_impactful_count > 0:
|
||||
summary["period_interval_smoothed_count"] = smoothed_impactful_count
|
||||
|
||||
# Add level gap tolerance information if any intervals were kept as gaps
|
||||
if level_gap_count > 0:
|
||||
summary["period_interval_level_gap_count"] = level_gap_count
|
||||
# Add optional interval flag counts (smoothing, level gaps, geometric extension)
|
||||
_add_interval_flag_counts(summary, period)
|
||||
|
||||
summaries.append(summary)
|
||||
|
||||
|
|
|
|||
|
|
@ -526,6 +526,7 @@ def calculate_periods_with_relaxation( # noqa: PLR0912, PLR0913, PLR0915 - Per-
|
|||
should_show_callback: Callable[[str | None], bool],
|
||||
time: TibberPricesTimeService,
|
||||
config_entry: Any, # ConfigEntry type
|
||||
day_patterns_by_date: dict | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Calculate periods with optional per-day filter relaxation.
|
||||
|
|
@ -552,6 +553,8 @@ def calculate_periods_with_relaxation( # noqa: PLR0912, PLR0913, PLR0915 - Per-
|
|||
to use original configured filter values.
|
||||
time: TibberPricesTimeService instance (required).
|
||||
config_entry: Config entry to get display unit configuration.
|
||||
day_patterns_by_date: Optional dict mapping date → day pattern dict. Used for
|
||||
geometric flex bonus in period detection. Passed through to calculate_periods().
|
||||
|
||||
Returns:
|
||||
Dict with same format as calculate_periods() output:
|
||||
|
|
@ -709,7 +712,7 @@ def calculate_periods_with_relaxation( # noqa: PLR0912, PLR0913, PLR0915 - Per-
|
|||
# === BASELINE CALCULATION (process ALL prices together, including yesterday) ===
|
||||
# Periods that ended before yesterday will be filtered out later by filter_periods_by_end_date()
|
||||
# This keeps yesterday/today/tomorrow periods in the cache
|
||||
baseline_result = calculate_periods(all_prices, config=config, time=time)
|
||||
baseline_result = calculate_periods(all_prices, config=config, time=time, day_patterns_by_date=day_patterns_by_date)
|
||||
all_periods = baseline_result["periods"]
|
||||
|
||||
# Count periods per day for min_periods check
|
||||
|
|
@ -765,6 +768,7 @@ def calculate_periods_with_relaxation( # noqa: PLR0912, PLR0913, PLR0915 - Per-
|
|||
baseline_periods=all_periods,
|
||||
time=time,
|
||||
config_entry=config_entry,
|
||||
day_patterns_by_date=day_patterns_by_date,
|
||||
)
|
||||
|
||||
all_periods = relaxed_result["periods"]
|
||||
|
|
@ -865,6 +869,7 @@ def relax_all_prices( # noqa: PLR0913 - Comprehensive filter relaxation require
|
|||
*,
|
||||
time: TibberPricesTimeService,
|
||||
config_entry: Any, # ConfigEntry type
|
||||
day_patterns_by_date: dict | None = None,
|
||||
) -> tuple[dict[str, Any], dict[str, Any]]:
|
||||
"""
|
||||
Relax filters for all prices until min_periods per day is reached.
|
||||
|
|
@ -883,6 +888,8 @@ def relax_all_prices( # noqa: PLR0913 - Comprehensive filter relaxation require
|
|||
baseline_periods: Baseline periods (before relaxation).
|
||||
time: TibberPricesTimeService instance.
|
||||
config_entry: Config entry to get display unit configuration.
|
||||
day_patterns_by_date: Optional dict mapping date → day pattern dict. Used for
|
||||
geometric flex bonus in period detection. Passed through to calculate_periods().
|
||||
|
||||
Returns:
|
||||
Tuple of (result_dict, metadata_dict)
|
||||
|
|
@ -947,7 +954,9 @@ 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, day_patterns_by_date=day_patterns_by_date
|
||||
)
|
||||
new_periods = result["periods"]
|
||||
|
||||
_LOGGER_DETAILS.debug(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,258 @@
|
|||
"""
|
||||
Shape-based period extension: extend periods into adjacent VERY_CHEAP/VERY_EXPENSIVE intervals.
|
||||
|
||||
After periods are identified by the core algorithm, this module optionally extends
|
||||
each period's boundaries to include any directly-adjacent intervals that carry the
|
||||
most extreme price level relevant to the period type:
|
||||
|
||||
- Best price periods → extend into VERY_CHEAP neighbouring intervals
|
||||
- Peak price periods → extend into VERY_EXPENSIVE neighbouring intervals
|
||||
|
||||
Extension is purely additive and opt-in (disabled by default). It does not affect
|
||||
the core period-finding logic; periods that would not normally be found are not
|
||||
created by this step.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import statistics
|
||||
from datetime import timedelta
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from custom_components.tibber_prices.const import (
|
||||
PRICE_LEVEL_VERY_CHEAP,
|
||||
PRICE_LEVEL_VERY_EXPENSIVE,
|
||||
)
|
||||
from custom_components.tibber_prices.utils.price import (
|
||||
aggregate_period_levels,
|
||||
aggregate_period_ratings,
|
||||
)
|
||||
|
||||
from .period_statistics import (
|
||||
calculate_aggregated_rating_difference,
|
||||
calculate_period_price_diff,
|
||||
calculate_period_price_statistics,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from datetime import datetime
|
||||
|
||||
from custom_components.tibber_prices.coordinator.time_service import TibberPricesTimeService
|
||||
|
||||
from .types import TibberPricesThresholdConfig
|
||||
|
||||
_INTERVAL_DURATION = timedelta(minutes=15)
|
||||
|
||||
|
||||
def extend_periods_for_shape( # noqa: PLR0913 - Extension requires all context params
|
||||
periods: list[dict[str, Any]],
|
||||
all_prices: list[dict[str, Any]],
|
||||
price_context: dict[str, Any],
|
||||
*,
|
||||
reverse_sort: bool,
|
||||
max_extension_intervals: int,
|
||||
thresholds: TibberPricesThresholdConfig,
|
||||
time: TibberPricesTimeService,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""
|
||||
Extend each period into adjacent VERY_CHEAP or VERY_EXPENSIVE intervals.
|
||||
|
||||
For best price periods (reverse_sort=False): extend into VERY_CHEAP neighbours.
|
||||
For peak price periods (reverse_sort=True): extend into VERY_EXPENSIVE neighbours.
|
||||
|
||||
Only intervals that are directly contiguous with the period and carry the
|
||||
target level are added. At most *max_extension_intervals* are consumed on
|
||||
each side independently. Period statistics are fully recalculated after
|
||||
any extension.
|
||||
|
||||
Args:
|
||||
periods: Period summary dicts from ``extract_period_summaries``.
|
||||
all_prices: All enriched price intervals (yesterday + today + tomorrow).
|
||||
price_context: Dict with ``ref_prices`` and ``avg_prices`` per calendar day.
|
||||
reverse_sort: ``True`` for peak price, ``False`` for best price.
|
||||
max_extension_intervals: Maximum extra intervals that may be added per side.
|
||||
thresholds: Threshold configuration for level / rating aggregation.
|
||||
time: Time-service instance used to resolve ``startsAt`` timestamps.
|
||||
|
||||
Returns:
|
||||
Updated list of period dicts, potentially with extended boundaries and
|
||||
recalculated statistics. Unmodified periods are returned as-is.
|
||||
|
||||
"""
|
||||
if not periods or max_extension_intervals <= 0:
|
||||
return periods
|
||||
|
||||
target_level = PRICE_LEVEL_VERY_EXPENSIVE if reverse_sort else PRICE_LEVEL_VERY_CHEAP
|
||||
|
||||
# Build a lookup dict: local datetime → full interval dict
|
||||
interval_index: dict[datetime, dict[str, Any]] = {}
|
||||
for iv in all_prices:
|
||||
t = time.get_interval_time(iv)
|
||||
if t is not None:
|
||||
interval_index[t] = iv
|
||||
|
||||
return [
|
||||
_extend_period_edges(
|
||||
period,
|
||||
interval_index,
|
||||
target_level=target_level,
|
||||
max_intervals=max_extension_intervals,
|
||||
thresholds=thresholds,
|
||||
price_context=price_context,
|
||||
)
|
||||
for period in periods
|
||||
]
|
||||
|
||||
|
||||
# ── private helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _extend_period_edges( # noqa: PLR0913, PLR0912, PLR0915 - Period edge extension requires many args, branches, and statements
|
||||
period: dict[str, Any],
|
||||
interval_index: dict[datetime, dict[str, Any]],
|
||||
*,
|
||||
target_level: str,
|
||||
max_intervals: int,
|
||||
thresholds: TibberPricesThresholdConfig,
|
||||
price_context: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Consume adjacent target-level intervals on both edges of a period.
|
||||
|
||||
The original period dict is never mutated; a new dict is returned.
|
||||
If no extension is possible, the original dict is returned unchanged.
|
||||
|
||||
Args:
|
||||
period: Period summary dict with ``start`` and ``end`` datetime keys.
|
||||
interval_index: Lookup map of ``{starts_at_datetime: interval_dict}``.
|
||||
target_level: ``"VERY_CHEAP"`` or ``"VERY_EXPENSIVE"``.
|
||||
max_intervals: Maximum intervals that may be added on each side.
|
||||
thresholds: Threshold config for aggregation helpers.
|
||||
price_context: Reference prices / averages per calendar day.
|
||||
|
||||
Returns:
|
||||
Extended (or original) period summary dict.
|
||||
|
||||
"""
|
||||
start: datetime = period["start"]
|
||||
end: datetime = period["end"]
|
||||
# ``end`` is the exclusive boundary: the last included interval starts at
|
||||
# ``end - _INTERVAL_DURATION``.
|
||||
|
||||
# ── walk LEFT (earlier than period start) ─────────────────────────────────
|
||||
left_additions: list[dict[str, Any]] = []
|
||||
cursor = start - _INTERVAL_DURATION
|
||||
for _ in range(max_intervals):
|
||||
iv = interval_index.get(cursor)
|
||||
if iv is None or iv.get("level") != target_level:
|
||||
break
|
||||
left_additions.insert(0, iv)
|
||||
cursor -= _INTERVAL_DURATION
|
||||
|
||||
# ── walk RIGHT (later than period end) ────────────────────────────────────
|
||||
right_additions: list[dict[str, Any]] = []
|
||||
cursor = end # first interval AFTER the period
|
||||
for _ in range(max_intervals):
|
||||
iv = interval_index.get(cursor)
|
||||
if iv is None or iv.get("level") != target_level:
|
||||
break
|
||||
right_additions.append(iv)
|
||||
cursor += _INTERVAL_DURATION
|
||||
|
||||
total_added = len(left_additions) + len(right_additions)
|
||||
if total_added == 0:
|
||||
return period
|
||||
|
||||
# ── rebuild full interval list for the extended period ────────────────────
|
||||
original_intervals = _collect_original_intervals(start, end, interval_index)
|
||||
all_period_intervals = left_additions + original_intervals + right_additions
|
||||
|
||||
# ── recalculate boundaries ────────────────────────────────────────────────
|
||||
new_start = start - _INTERVAL_DURATION * len(left_additions)
|
||||
new_end = end + _INTERVAL_DURATION * len(right_additions)
|
||||
new_duration_minutes = int((new_end - new_start).total_seconds() // 60)
|
||||
new_interval_count = len(all_period_intervals)
|
||||
|
||||
# ── recalculate price statistics ──────────────────────────────────────────
|
||||
price_stats = calculate_period_price_statistics(all_period_intervals)
|
||||
period_price_diff, period_price_diff_pct = calculate_period_price_diff(
|
||||
price_stats["price_mean"], new_start, price_context
|
||||
)
|
||||
rating_diff_pct = calculate_aggregated_rating_difference(all_period_intervals)
|
||||
|
||||
# ── recalculate level / rating aggregates ─────────────────────────────────
|
||||
new_level = aggregate_period_levels(all_period_intervals)
|
||||
new_rating: str | None = None
|
||||
if thresholds.threshold_low is not None and thresholds.threshold_high is not None:
|
||||
new_rating, _ = aggregate_period_ratings(
|
||||
all_period_intervals,
|
||||
thresholds.threshold_low,
|
||||
thresholds.threshold_high,
|
||||
)
|
||||
|
||||
# ── recalculate volatility (coefficient of variation) ────────────────────
|
||||
prices_for_vol = [float(p["total"]) for p in all_period_intervals if "total" in p]
|
||||
cv_pct: float | None = None
|
||||
if len(prices_for_vol) >= 2: # noqa: PLR2004
|
||||
mean_p = statistics.mean(prices_for_vol)
|
||||
if mean_p > 0:
|
||||
cv_pct = round(statistics.stdev(prices_for_vol) / mean_p * 100, 1)
|
||||
|
||||
# ── assemble updated period dict (keep structural fields, update statistics) ─
|
||||
reverse_sort = target_level == PRICE_LEVEL_VERY_EXPENSIVE
|
||||
updated: dict[str, Any] = {
|
||||
**period,
|
||||
# Time fields
|
||||
"start": new_start,
|
||||
"end": new_end,
|
||||
"duration_minutes": new_duration_minutes,
|
||||
# Core decision attributes
|
||||
"level": new_level,
|
||||
"rating_level": new_rating,
|
||||
"rating_difference_%": rating_diff_pct,
|
||||
# Price statistics
|
||||
"price_mean": price_stats["price_mean"],
|
||||
"price_median": price_stats["price_median"],
|
||||
"price_min": price_stats["price_min"],
|
||||
"price_max": price_stats["price_max"],
|
||||
"price_spread": price_stats["price_spread"],
|
||||
"price_coefficient_variation_%": cv_pct,
|
||||
# Detail
|
||||
"period_interval_count": new_interval_count,
|
||||
# Extension metadata
|
||||
"extension_intervals_added": total_added,
|
||||
}
|
||||
|
||||
# Refresh period price diff (replaces old value from base period)
|
||||
if reverse_sort:
|
||||
updated.pop("period_price_diff_from_daily_min", None)
|
||||
updated.pop("period_price_diff_from_daily_min_%", None)
|
||||
if period_price_diff is not None:
|
||||
updated["period_price_diff_from_daily_max"] = period_price_diff
|
||||
if period_price_diff_pct is not None:
|
||||
updated["period_price_diff_from_daily_max_%"] = period_price_diff_pct
|
||||
else:
|
||||
updated.pop("period_price_diff_from_daily_max", None)
|
||||
updated.pop("period_price_diff_from_daily_max_%", None)
|
||||
if period_price_diff is not None:
|
||||
updated["period_price_diff_from_daily_min"] = period_price_diff
|
||||
if period_price_diff_pct is not None:
|
||||
updated["period_price_diff_from_daily_min_%"] = period_price_diff_pct
|
||||
|
||||
return updated
|
||||
|
||||
|
||||
def _collect_original_intervals(
|
||||
start: datetime,
|
||||
end: datetime,
|
||||
interval_index: dict[datetime, dict[str, Any]],
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Reconstruct the ordered interval list for an existing period from the index."""
|
||||
result: list[dict[str, Any]] = []
|
||||
cursor = start
|
||||
while cursor < end:
|
||||
iv = interval_index.get(cursor)
|
||||
if iv is not None:
|
||||
result.append(iv)
|
||||
cursor += _INTERVAL_DURATION
|
||||
return result
|
||||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, NamedTuple
|
||||
from typing import TYPE_CHECKING, NamedTuple, TypedDict
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from datetime import datetime
|
||||
|
|
@ -56,6 +56,9 @@ class TibberPricesPeriodConfig(NamedTuple):
|
|||
threshold_volatility_very_high: float = DEFAULT_VOLATILITY_THRESHOLD_VERY_HIGH
|
||||
level_filter: str | None = None # "any", "cheap", "expensive", etc. or None
|
||||
gap_count: int = 0 # Number of allowed consecutive deviating intervals
|
||||
extend_to_extreme: bool = False # Extend periods into adjacent VERY_CHEAP/VERY_EXPENSIVE intervals
|
||||
max_extension_intervals: int = 0 # Max intervals this extension may add per side (0 = disabled)
|
||||
geometric_extra_flex: float = 0.0 # Extra flex (decimal) for intervals inside the valley/peak zone (0.0 = disabled)
|
||||
|
||||
|
||||
class TibberPricesPeriodData(NamedTuple):
|
||||
|
|
@ -104,3 +107,60 @@ class TibberPricesIntervalCriteria(NamedTuple):
|
|||
flex: float
|
||||
min_distance_from_avg: float
|
||||
reverse_sort: bool
|
||||
|
||||
|
||||
# ─── Day pattern constants ─────────────────────────────────────────────────────
|
||||
|
||||
DAY_PATTERN_VALLEY = "valley" # Single price minimum (U/V-shape)
|
||||
DAY_PATTERN_PEAK = "peak" # Single price maximum (Λ-shape)
|
||||
DAY_PATTERN_DOUBLE_VALLEY = "double_valley" # Two minima, W-shape
|
||||
DAY_PATTERN_DOUBLE_PEAK = "double_peak" # Two peaks, M-shape
|
||||
DAY_PATTERN_FLAT = "flat" # No significant variation
|
||||
DAY_PATTERN_RISING = "rising" # Persistently rising throughout the day
|
||||
DAY_PATTERN_FALLING = "falling" # Persistently falling throughout the day
|
||||
DAY_PATTERN_MIXED = "mixed" # Multiple extrema with no clear pattern
|
||||
|
||||
# Ordered list used to populate SensorDeviceClass.ENUM options=
|
||||
ALL_DAY_PATTERNS: list[str] = [
|
||||
DAY_PATTERN_VALLEY,
|
||||
DAY_PATTERN_PEAK,
|
||||
DAY_PATTERN_DOUBLE_VALLEY,
|
||||
DAY_PATTERN_DOUBLE_PEAK,
|
||||
DAY_PATTERN_FLAT,
|
||||
DAY_PATTERN_RISING,
|
||||
DAY_PATTERN_FALLING,
|
||||
DAY_PATTERN_MIXED,
|
||||
]
|
||||
|
||||
# Segment type constants
|
||||
DAY_SEGMENT_RISING = "rising"
|
||||
DAY_SEGMENT_FALLING = "falling"
|
||||
DAY_SEGMENT_FLAT = "flat"
|
||||
|
||||
|
||||
# ─── Day pattern TypedDicts ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class SegmentDict(TypedDict):
|
||||
"""One monotone price segment within a calendar day."""
|
||||
|
||||
type: str # "rising" | "falling" | "flat"
|
||||
start: str | None # ISO datetime of first interval in segment
|
||||
end: str | None # ISO datetime of last interval in segment
|
||||
price_min: float # Minimum price in segment
|
||||
price_max: float # Maximum price in segment
|
||||
price_mean: float # Mean price in segment
|
||||
|
||||
|
||||
class DayPatternDict(TypedDict):
|
||||
"""Detected price pattern for one calendar day."""
|
||||
|
||||
pattern: str # One of the DAY_PATTERN_* constants
|
||||
confidence: float # 0.0 - 1.0
|
||||
day_cv_percent: float # Coefficient of variation for the day (%)
|
||||
segments: list[SegmentDict] # Monotone segments
|
||||
extreme_time: str | None # ISO datetime of primary extremum (valley/peak)
|
||||
valley_start: str | None # ISO datetime of left knee (VALLEY pattern only)
|
||||
valley_end: str | None # ISO datetime of right knee (VALLEY pattern only)
|
||||
peak_start: str | None # ISO datetime of left knee (PEAK pattern only)
|
||||
peak_end: str | None # ISO datetime of right knee (PEAK pattern only)
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ gap tolerance, and coordination of the period_handlers calculation functions.
|
|||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import date, timedelta
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from custom_components.tibber_prices import const as _const
|
||||
|
|
@ -225,6 +226,60 @@ class TibberPricesPeriodCalculator:
|
|||
"min_period_length": int(min_period_length),
|
||||
}
|
||||
|
||||
# Extension settings (stored in 'extension_settings' nested section)
|
||||
if reverse_sort:
|
||||
extend_to_extreme = bool(
|
||||
self._get_option(
|
||||
_const.CONF_PEAK_PRICE_EXTEND_TO_VERY_EXPENSIVE,
|
||||
"extension_settings",
|
||||
_const.DEFAULT_PEAK_PRICE_EXTEND_TO_VERY_EXPENSIVE,
|
||||
)
|
||||
)
|
||||
max_extension_intervals = int(
|
||||
self._get_option(
|
||||
_const.CONF_PEAK_PRICE_MAX_EXTENSION_INTERVALS,
|
||||
"extension_settings",
|
||||
_const.DEFAULT_PEAK_PRICE_MAX_EXTENSION_INTERVALS,
|
||||
)
|
||||
)
|
||||
else:
|
||||
extend_to_extreme = bool(
|
||||
self._get_option(
|
||||
_const.CONF_BEST_PRICE_EXTEND_TO_VERY_CHEAP,
|
||||
"extension_settings",
|
||||
_const.DEFAULT_BEST_PRICE_EXTEND_TO_VERY_CHEAP,
|
||||
)
|
||||
)
|
||||
max_extension_intervals = int(
|
||||
self._get_option(
|
||||
_const.CONF_BEST_PRICE_MAX_EXTENSION_INTERVALS,
|
||||
"extension_settings",
|
||||
_const.DEFAULT_BEST_PRICE_MAX_EXTENSION_INTERVALS,
|
||||
)
|
||||
)
|
||||
|
||||
config["extend_to_extreme"] = extend_to_extreme
|
||||
config["max_extension_intervals"] = max_extension_intervals
|
||||
|
||||
# Geometric flex bonus (intervals inside valley/peak zone get extra flex)
|
||||
if reverse_sort:
|
||||
geometric_flex_pct = int(
|
||||
self._get_option(
|
||||
_const.CONF_PEAK_PRICE_GEOMETRIC_FLEX,
|
||||
"extension_settings",
|
||||
_const.DEFAULT_PEAK_PRICE_GEOMETRIC_FLEX,
|
||||
)
|
||||
)
|
||||
else:
|
||||
geometric_flex_pct = int(
|
||||
self._get_option(
|
||||
_const.CONF_BEST_PRICE_GEOMETRIC_FLEX,
|
||||
"extension_settings",
|
||||
_const.DEFAULT_BEST_PRICE_GEOMETRIC_FLEX,
|
||||
)
|
||||
)
|
||||
config["geometric_extra_flex"] = geometric_flex_pct / 100
|
||||
|
||||
# Cache the result
|
||||
self._config_cache[cache_key] = config
|
||||
self._config_cache_valid = True
|
||||
|
|
@ -598,6 +653,7 @@ class TibberPricesPeriodCalculator:
|
|||
def calculate_periods_for_price_info(
|
||||
self,
|
||||
price_info: dict[str, Any],
|
||||
day_patterns: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Calculate periods (best price and peak price) for the given price info.
|
||||
|
|
@ -622,6 +678,19 @@ class TibberPricesPeriodCalculator:
|
|||
coordinator_data = {"priceInfo": price_info}
|
||||
all_prices = get_intervals_for_day_offsets(coordinator_data, [-2, -1, 0, 1])
|
||||
|
||||
# Convert day_patterns (keyed by "yesterday"/"today"/"tomorrow") to date-keyed dict
|
||||
# Needed for geometric valley/peak zone flex bonus in period calculation
|
||||
today_date = self.time.now().date()
|
||||
day_patterns_by_date: dict[date, dict[str, Any]] | None = (
|
||||
{
|
||||
today_date + timedelta(days=ofs): pat
|
||||
for ofs, lbl in ((-1, "yesterday"), (0, "today"), (1, "tomorrow"))
|
||||
if (pat := day_patterns.get(lbl)) is not None
|
||||
}
|
||||
if day_patterns
|
||||
else None
|
||||
)
|
||||
|
||||
# Get rating thresholds from config (flat in options, not in sections)
|
||||
# CRITICAL: Price rating thresholds are stored FLAT in options (no sections)
|
||||
threshold_low = self.config_entry.options.get(
|
||||
|
|
@ -702,6 +771,9 @@ class TibberPricesPeriodCalculator:
|
|||
threshold_volatility_very_high=threshold_volatility_very_high,
|
||||
level_filter=max_level_best,
|
||||
gap_count=gap_count_best,
|
||||
extend_to_extreme=best_config["extend_to_extreme"],
|
||||
max_extension_intervals=best_config["max_extension_intervals"],
|
||||
geometric_extra_flex=best_config["geometric_extra_flex"],
|
||||
)
|
||||
best_periods = calculate_periods_with_relaxation(
|
||||
all_prices,
|
||||
|
|
@ -716,6 +788,7 @@ class TibberPricesPeriodCalculator:
|
|||
),
|
||||
time=self.time,
|
||||
config_entry=self.config_entry,
|
||||
day_patterns_by_date=day_patterns_by_date,
|
||||
)
|
||||
else:
|
||||
best_periods = {
|
||||
|
|
@ -783,6 +856,9 @@ class TibberPricesPeriodCalculator:
|
|||
threshold_volatility_very_high=threshold_volatility_very_high,
|
||||
level_filter=min_level_peak,
|
||||
gap_count=gap_count_peak,
|
||||
extend_to_extreme=peak_config["extend_to_extreme"],
|
||||
max_extension_intervals=peak_config["max_extension_intervals"],
|
||||
geometric_extra_flex=peak_config["geometric_extra_flex"],
|
||||
)
|
||||
peak_periods = calculate_periods_with_relaxation(
|
||||
all_prices,
|
||||
|
|
@ -797,6 +873,7 @@ class TibberPricesPeriodCalculator:
|
|||
),
|
||||
time=self.time,
|
||||
config_entry=self.config_entry,
|
||||
day_patterns_by_date=day_patterns_by_date,
|
||||
)
|
||||
else:
|
||||
peak_periods = {
|
||||
|
|
|
|||
|
|
@ -486,6 +486,21 @@
|
|||
"long_description": "Zeigt, ob dein Tibber-Abonnement derzeit aktiv ist, beendet wurde oder auf Aktivierung wartet. Ein Status 'Aktiv' bedeutet, dass du aktiv Strom über Tibber beziehst.",
|
||||
"usage_tips": "Nutze dies zur Überwachung deines Abonnementstatus. Richte Benachrichtigungen ein, wenn sich der Status von 'Aktiv' ändert, um einen unterbrechungsfreien Service sicherzustellen."
|
||||
},
|
||||
"day_pattern_yesterday": {
|
||||
"description": "Erkanntes Preismuster der gestrigen Strompreise",
|
||||
"long_description": "Klassifiziert gestern in ein Preismuster: Tal (günstig in der Mitte), Gipfel (teuer in der Mitte), Doppeltal (zwei günstige Perioden), Doppelgipfel (zwei teure Perioden), Flach (geringe Variation), Steigend, Fallend oder Gemischt. Die Konfidenz- und CV-Attribute zeigen, wie verlässlich das Muster erkannt wurde.",
|
||||
"usage_tips": "Nutze das gestrige Muster für Automationen: Ein Tal-Tag wiederholt sich oft am nächsten Tag und deutet darauf hin, dass Verbraucher auf die günstigen Mittagsstunden verschoben werden sollten."
|
||||
},
|
||||
"day_pattern_today": {
|
||||
"description": "Erkanntes Preismuster der heutigen Strompreise",
|
||||
"long_description": "Klassifiziert heute in ein Preismuster: Tal (günstig mittags), Gipfel (teuer mittags), Doppeltal (W-Form), Doppelgipfel (M-Form), Flach, Steigend, Fallend oder Gemischt. Attribute enthalten Konfidenz (0–1), Variationskoeffizient, Knickpunktzeiten und Tagessegmente.",
|
||||
"usage_tips": "Nutze das Tagesmuster, um Verbraucher zu verschieben. Tal-Tag: Spülmaschine, Waschmaschine oder E-Auto-Laden in die günstige Mittagszeit legen. Gipfel-Tag: früh morgens oder spät abends waschen. Die Attribute valley_start und valley_end ermöglichen minutengenaue Automationen."
|
||||
},
|
||||
"day_pattern_tomorrow": {
|
||||
"description": "Erkanntes Preismuster der morgigen Strompreise",
|
||||
"long_description": "Klassifiziert morgen (sobald Daten verfügbar sind, typisch nach 13 Uhr) in ein Preismuster mit demselben Algorithmus wie heute. Die Attribute valley_start/valley_end oder peak_start/peak_end geben Knickpunktzeiten für das primäre Extremum an.",
|
||||
"usage_tips": "Richte Abendautomationen ein, die das morgige Muster lesen und Wärmepumpe, Autolader oder Warmwasserbereiter für den nächsten Tag vorkonfigurieren. Kombiniere mit dem tomorrow_data_available Binärsensor."
|
||||
},
|
||||
"chart_data_export": {
|
||||
"description": "Datenexport für Dashboard-Integrationen",
|
||||
"long_description": "Dieser Sensor ruft den get_chartdata-Service mit deiner konfigurierten YAML-Konfiguration auf und stellt das Ergebnis als Entity-Attribute bereit. Der Status zeigt 'ready' wenn Daten verfügbar sind, 'error' bei Fehlern, oder 'pending' vor dem ersten Aufruf. Perfekt für Dashboard-Integrationen wie ApexCharts, die Preisdaten aus Entity-Attributen lesen.",
|
||||
|
|
|
|||
|
|
@ -486,6 +486,21 @@
|
|||
"long_description": "Shows whether your Tibber subscription is currently running, has ended, or is pending activation. A status of 'running' means you're actively receiving electricity through Tibber.",
|
||||
"usage_tips": "Use this to monitor your subscription status. Set up alerts if status changes from 'running' to ensure uninterrupted service."
|
||||
},
|
||||
"day_pattern_yesterday": {
|
||||
"description": "Detected price shape of yesterday's electricity prices",
|
||||
"long_description": "Classifies yesterday into a price shape: Valley (cheap in the middle), Peak (expensive in the middle), Double Valley (two cheap periods), Double Peak (two expensive periods), Flat (little variation), Rising, Falling, or Mixed. The confidence and CV attributes indicate how reliably the pattern was detected.",
|
||||
"usage_tips": "Use yesterday's pattern to refine automations: a Valley day often repeats the next day, suggesting you should pre-schedule cheap-hour loads. Pair with the confidence attribute to filter unreliable detections."
|
||||
},
|
||||
"day_pattern_today": {
|
||||
"description": "Detected price shape of today's electricity prices",
|
||||
"long_description": "Classifies today into a price shape: Valley (cheap in the middle of the day), Peak (expensive in the middle), Double Valley (W-shape, two cheap windows), Double Peak (M-shape, two expensive peaks), Flat (prices barely move), Rising (prices climb through the day), Falling (prices drop through the day), or Mixed. Attributes include confidence (0\u20131), coefficient of variation, knee-point times, and intra-day segments.",
|
||||
"usage_tips": "Use today's pattern to decide when to shift loads. A Valley day means cheap prices around midday \u2014 ideal for running the dishwasher, washing machine, or charging the EV. A Peak day means expensive midday \u2014 run appliances early morning or late evening. Use valley_start and valley_end attributes to schedule automations precisely."
|
||||
},
|
||||
"day_pattern_tomorrow": {
|
||||
"description": "Detected price shape of tomorrow's electricity prices",
|
||||
"long_description": "Classifies tomorrow (once data is available, typically after 13:00) into a price shape using the same algorithm as today. The valley_start / valley_end or peak_start / peak_end attributes give knee-point times for the primary extremum so you can pre-schedule loads the evening before.",
|
||||
"usage_tips": "Set up evening automations that read tomorrow's pattern and pre-configure heat pump schedules, car charging timers, or water heater settings for the following day. Pair with the tomorrow_data_available binary sensor to trigger the automation only when data is ready."
|
||||
},
|
||||
"chart_data_export": {
|
||||
"description": "Data export for dashboard integrations",
|
||||
"long_description": "This binary sensor calls the get_chartdata service with your configured YAML parameters and exposes the result as entity attributes. The state is 'on' when the service call succeeds and data is available, 'off' when the call fails or no configuration is set. Perfect for dashboard integrations like ApexCharts that need to read price data from entity attributes.",
|
||||
|
|
|
|||
|
|
@ -486,6 +486,21 @@
|
|||
"long_description": "Viser om Tibber-abonnementet ditt for øyeblikket er aktivt, avsluttet eller venter på aktivering. En status 'Aktiv' betyr at du aktivt mottar strøm gjennom Tibber.",
|
||||
"usage_tips": "Bruk dette til å overvåke abonnementsstatusen din. Sett opp varsler hvis statusen endres fra 'Aktiv' for å sikre uavbrutt tjeneste."
|
||||
},
|
||||
"day_pattern_yesterday": {
|
||||
"description": "Oppdaget prismønster for gårsdagens strømpriser",
|
||||
"long_description": "Klassifiserer i går i et prismønster: Dal (billig midt på dagen), Topp (dyrt midt på dagen), Dobbel dal (to billige perioder), Dobbel topp (to dyre perioder), Flat (liten variasjon), Stigende, Fallende eller Blandet. Konfidensen og CV-attributtene viser hvor pålitelig mønsteret ble oppdaget.",
|
||||
"usage_tips": "Bruk gårsdagens mønster til å forbedre automatikaene dine: et Dalmønster gjentar seg ofte neste dag og antyder at du bør forhåndsplanlegge billige middagstimer."
|
||||
},
|
||||
"day_pattern_today": {
|
||||
"description": "Oppdaget prismønster for dagens strømpriser",
|
||||
"long_description": "Klassifiserer i dag i et prismønster: Dal (billig midt på dagen), Topp (dyrt midt på dagen), Dobbel dal (W-form), Dobbel topp (M-form), Flat, Stigende, Fallende eller Blandet. Attributter inkluderer konfidensverdi (0–1), variasjonskoeffisient, knepunktstider og dagsegmenter.",
|
||||
"usage_tips": "Bruk dagens mønster til å flytte forbruk. Daldag: kjør oppvaskmaskin, vaskemaskin eller lad elbilen rundt billige middagstimer. Toppdag: kjør apparater tidlig morgen eller sent kveld. Bruk valley_start og valley_end for presise automatikaer."
|
||||
},
|
||||
"day_pattern_tomorrow": {
|
||||
"description": "Oppdaget prismønster for morgendagens strømpriser",
|
||||
"long_description": "Klassifiserer i morgen (når data er tilgjengelig, typisk etter kl. 13) i et prismønster med samme algoritme som i dag. Attributtene valley_start/valley_end eller peak_start/peak_end gir knepunktstider.",
|
||||
"usage_tips": "Sett opp kveldsautomasjonar som leser morgendagens mønster og forhåndskonfigurerer varmepumpe, billader eller varmtvannsberedere. Kombiner med tomorrow_data_available-binærsensoren."
|
||||
},
|
||||
"chart_data_export": {
|
||||
"description": "Dataeksport for dashboardintegrasjoner",
|
||||
"long_description": "Denne sensoren kaller get_chartdata-tjenesten med din konfigurerte YAML-konfigurasjon og eksponerer resultatet som entitetsattributter. Status viser 'ready' når data er tilgjengelig, 'error' ved feil, eller 'pending' før første kall. Perfekt for dashboardintegrasjoner som ApexCharts som trenger å lese prisdata fra entitetsattributter.",
|
||||
|
|
|
|||
|
|
@ -486,6 +486,21 @@
|
|||
"long_description": "Geeft aan of je Tibber-abonnement momenteel actief is, beëindigd of wacht op activering. Een 'Actief'-status betekent dat je actief elektriciteit via Tibber afneemt.",
|
||||
"usage_tips": "Gebruik dit om je abonnementsstatus te monitoren. Stel meldingen in als de status verandert van 'Actief' om ononderbroken service te waarborgen."
|
||||
},
|
||||
"day_pattern_yesterday": {
|
||||
"description": "Gedetecteerd prijspatroon van gisterens elektriciteitsprijzen",
|
||||
"long_description": "Classificeert gisteren in een prijspatroon: Dal (goedkoop in het midden), Piek (duur in het midden), Dubbel Dal (twee goedkope perioden), Dubbele Piek (twee dure perioden), Vlak (weinig variatie), Stijgend, Dalend of Gemengd. De confidence- en CV-attributen tonen hoe betrouwbaar het patroon is gedetecteerd.",
|
||||
"usage_tips": "Gebruik het patroon van gisteren om automations te verfijnen: een Daldag herhaalt zich vaak de volgende dag en suggereert om goedkope middaguren in te plannen."
|
||||
},
|
||||
"day_pattern_today": {
|
||||
"description": "Gedetecteerd prijspatroon van de huidige elektriciteitsprijzen",
|
||||
"long_description": "Classificeert vandaag in een prijspatroon: Dal (goedkoop 's middags), Piek (duur 's middags), Dubbel Dal (W-vorm), Dubbele Piek (M-vorm), Vlak, Stijgend, Dalend of Gemengd. Attributen omvatten confidence (0–1), variatiecoëfficiënt, kniepunttijden en dagsegmenten.",
|
||||
"usage_tips": "Gebruik het dagpatroon om verbruik te verschuiven. Daldag: draai vaatwasser, wasmachine of laad de EV 's middags. Piekdag: gebruik apparaten vroeg in de ochtend of laat in de avond. Gebruik valley_start en valley_end voor precieze automations."
|
||||
},
|
||||
"day_pattern_tomorrow": {
|
||||
"description": "Gedetecteerd prijspatroon van de elektriciteitsprijzen van morgen",
|
||||
"long_description": "Classificeert morgen (zodra data beschikbaar is, doorgaans na 13:00) in een prijspatroon met hetzelfde algoritme als vandaag. De attributen valley_start/valley_end of peak_start/peak_end geven kniepunttijden voor het primaire extremum.",
|
||||
"usage_tips": "Stel avondautomations in die het patroon van morgen lezen en warmtepomp, autolader of boiler vooraf configureren. Combineer met de tomorrow_data_available binaire sensor."
|
||||
},
|
||||
"chart_data_export": {
|
||||
"description": "Data-export voor dashboard-integraties",
|
||||
"long_description": "Deze sensor roept de get_chartdata-service aan met jouw geconfigureerde YAML-configuratie en stelt het resultaat beschikbaar als entiteitsattributen. De status toont 'ready' wanneer data beschikbaar is, 'error' bij fouten, of 'pending' voor de eerste aanroep. Perfekt voor dashboard-integraties zoals ApexCharts die prijsgegevens uit entiteitsattributen moeten lezen.",
|
||||
|
|
|
|||
|
|
@ -486,6 +486,21 @@
|
|||
"long_description": "Visar om ditt Tibber-abonnemang för närvarande är aktivt, har avslutats eller väntar på aktivering. En status 'Aktiv' betyder att du aktivt tar emot elektricitet genom Tibber.",
|
||||
"usage_tips": "Använd detta för att övervaka din abonnemangsstatus. Ställ in varningar om statusen ändras från 'Aktiv' för att säkerställa oavbruten service."
|
||||
},
|
||||
"day_pattern_yesterday": {
|
||||
"description": "Detekterat prismönster för gårdagens elpriser",
|
||||
"long_description": "Klassificerar igår i ett prismönster: Dal (billigt på mitten), Topp (dyrt på mitten), Dubbeldal (W-form, två billiga perioder), Dubbeltopp (M-form, två dyra toppar), Flat (liten variation), Stigande, Fallande eller Blandad. Konfidensen och CV-attributen visar hur tillförlitligt mönstret detekterades.",
|
||||
"usage_tips": "Använd gårdagens mönster för att förfina automationer: ett Dalmönster upprepas ofta nästa dag och tyder på att du bör förplanera billiga middagstimmar."
|
||||
},
|
||||
"day_pattern_today": {
|
||||
"description": "Detekterat prismönster för dagens elpriser",
|
||||
"long_description": "Klassificerar idag i ett prismönster: Dal (billigt på middagen), Topp (dyrt på middagen), Dubbeldal (W-form), Dubbeltopp (M-form), Flat, Stigande, Fallande eller Blandad. Attributen inkluderar konfidenspoäng (0–1), variationskoefficient, knäpunkttider och dagsegment.",
|
||||
"usage_tips": "Använd dagens mönster för att flytta förbrukning. Daldag: kör diskmaskinen, tvättmaskinen eller ladda elbilen på middagen. Toppdag: kör apparater tidigt på morgonen eller sent på kvällen. Använd valley_start och valley_end för precisa automationer."
|
||||
},
|
||||
"day_pattern_tomorrow": {
|
||||
"description": "Detekterat prismönster för morgondagens elpriser",
|
||||
"long_description": "Klassificerar imorgon (när data finns tillgänglig, vanligtvis efter 13:00) i ett prismönster med samma algoritm som idag. Attributen valley_start/valley_end eller peak_start/peak_end ger knäpunkttider för det primära extremvärdet.",
|
||||
"usage_tips": "Ställ in kvällsautomationer som läser morgondagens mönster och förkonfigurerar värmepump, billaddare eller varmvattenberedare. Kombinera med tomorrow_data_available-binärsensorn."
|
||||
},
|
||||
"chart_data_export": {
|
||||
"description": "Dataexport för dashboard-integrationer",
|
||||
"long_description": "Denna sensor anropar get_chartdata-tjänsten med din konfigurerade YAML-konfiguration och exponerar resultatet som entitetsattribut. Statusen visar 'ready' när data är tillgänglig, 'error' vid fel, eller 'pending' före första anropet. Perfekt för dashboard-integrationer som ApexCharts som behöver läsa prisdata från entitetsattribut.",
|
||||
|
|
|
|||
|
|
@ -28,6 +28,52 @@
|
|||
},
|
||||
"refresh_user_data": {
|
||||
"service": "mdi:refresh"
|
||||
},
|
||||
"find_cheapest_block": {
|
||||
"service": "mdi:washing-machine",
|
||||
"sections": {
|
||||
"search_range": "mdi:calendar-search",
|
||||
"time_alternatives": "mdi:clock-time-eight-outline",
|
||||
"price_filter": "mdi:filter-variant",
|
||||
"output": "mdi:tune-variant"
|
||||
}
|
||||
},
|
||||
"find_most_expensive_block": {
|
||||
"service": "mdi:lightning-bolt-circle",
|
||||
"sections": {
|
||||
"search_range": "mdi:calendar-search",
|
||||
"time_alternatives": "mdi:clock-time-eight-outline",
|
||||
"price_filter": "mdi:filter-variant",
|
||||
"output": "mdi:tune-variant"
|
||||
}
|
||||
},
|
||||
"find_cheapest_hours": {
|
||||
"service": "mdi:ev-station",
|
||||
"sections": {
|
||||
"search_range": "mdi:calendar-search",
|
||||
"time_alternatives": "mdi:clock-time-eight-outline",
|
||||
"price_filter": "mdi:filter-variant",
|
||||
"output": "mdi:tune-variant"
|
||||
}
|
||||
},
|
||||
"find_most_expensive_hours": {
|
||||
"service": "mdi:flash-alert",
|
||||
"sections": {
|
||||
"search_range": "mdi:calendar-search",
|
||||
"time_alternatives": "mdi:clock-time-eight-outline",
|
||||
"price_filter": "mdi:filter-variant",
|
||||
"output": "mdi:tune-variant"
|
||||
}
|
||||
},
|
||||
"find_cheapest_schedule": {
|
||||
"service": "mdi:calendar-check",
|
||||
"sections": {
|
||||
"scheduling_options": "mdi:format-list-numbered",
|
||||
"search_range": "mdi:calendar-search",
|
||||
"time_alternatives": "mdi:clock-time-eight-outline",
|
||||
"price_filter": "mdi:filter-variant",
|
||||
"output": "mdi:tune-variant"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ from .daily_stat import add_statistics_attributes
|
|||
from .future import add_next_avg_attributes, get_future_prices
|
||||
from .interval import add_current_interval_price_attributes
|
||||
from .lifecycle import build_lifecycle_attributes
|
||||
from .metadata import get_day_pattern_attributes
|
||||
from .timing import _is_timing_or_volatility_sensor
|
||||
from .trend import _add_cached_trend_attributes, _add_timing_or_volatility_attributes
|
||||
from .volatility import add_volatility_type_attributes, get_prices_for_volatility
|
||||
|
|
@ -72,7 +73,7 @@ __all__ = [
|
|||
]
|
||||
|
||||
|
||||
def build_sensor_attributes(
|
||||
def build_sensor_attributes( # noqa: PLR0912
|
||||
key: str,
|
||||
coordinator: TibberPricesDataUpdateCoordinator,
|
||||
native_value: Any,
|
||||
|
|
@ -189,6 +190,12 @@ def build_sensor_attributes(
|
|||
elif _is_timing_or_volatility_sensor(key):
|
||||
_add_timing_or_volatility_attributes(attributes, key, cached_data, native_value, time=time)
|
||||
|
||||
elif key in ("day_pattern_yesterday", "day_pattern_today", "day_pattern_tomorrow"):
|
||||
day = key.removeprefix("day_pattern_")
|
||||
day_attrs = get_day_pattern_attributes(coordinator, day)
|
||||
if day_attrs:
|
||||
attributes.update(day_attrs)
|
||||
|
||||
# For current_interval_price_level, add the original level as attribute
|
||||
if key == "current_interval_price_level" and cached_data.get("last_price_level") is not None:
|
||||
attributes["level_id"] = cached_data["last_price_level"]
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from custom_components.tibber_prices.utils.price import find_price_data_for_interval
|
||||
|
||||
|
|
@ -35,3 +35,77 @@ def get_current_interval_data(
|
|||
now = time.now()
|
||||
|
||||
return find_price_data_for_interval(coordinator.data, now, time=time)
|
||||
|
||||
|
||||
def get_day_pattern_attributes(
|
||||
coordinator: TibberPricesDataUpdateCoordinator,
|
||||
day: str,
|
||||
) -> dict[str, Any] | None:
|
||||
"""
|
||||
Build attributes for a day_pattern_* sensor.
|
||||
|
||||
Returns the full DayPatternDict fields (except "pattern" which is the sensor
|
||||
state) plus ISO-formatted datetime fields.
|
||||
|
||||
Args:
|
||||
coordinator: The data update coordinator.
|
||||
day: One of "yesterday", "today", "tomorrow".
|
||||
time: TibberPricesTimeService instance.
|
||||
|
||||
Returns:
|
||||
Attribute dict or None if pattern data is unavailable.
|
||||
|
||||
"""
|
||||
if not coordinator.data:
|
||||
return None
|
||||
|
||||
day_patterns = coordinator.data.get("dayPatterns")
|
||||
if not day_patterns:
|
||||
return None
|
||||
|
||||
day_data: dict[str, Any] | None = day_patterns.get(day)
|
||||
if not day_data:
|
||||
return None
|
||||
|
||||
def _iso(val: object) -> str | None:
|
||||
"""Convert datetime to ISO string, pass strings through, return None otherwise."""
|
||||
if val is None:
|
||||
return None
|
||||
if isinstance(val, str):
|
||||
return val
|
||||
if hasattr(val, "isoformat"):
|
||||
return val.isoformat() # type: ignore[return-value]
|
||||
return None
|
||||
|
||||
attrs: dict[str, Any] = {
|
||||
"confidence": day_data.get("confidence"),
|
||||
"day_cv_percent": day_data.get("day_cv_percent"),
|
||||
}
|
||||
|
||||
# Optional primary extreme time
|
||||
extreme_time = _iso(day_data.get("extreme_time"))
|
||||
if extreme_time is not None:
|
||||
attrs["extreme_time"] = extreme_time
|
||||
|
||||
# VALLEY-specific knee points
|
||||
valley_start = _iso(day_data.get("valley_start"))
|
||||
valley_end = _iso(day_data.get("valley_end"))
|
||||
if valley_start is not None:
|
||||
attrs["valley_start"] = valley_start
|
||||
if valley_end is not None:
|
||||
attrs["valley_end"] = valley_end
|
||||
|
||||
# PEAK-specific knee points
|
||||
peak_start = _iso(day_data.get("peak_start"))
|
||||
peak_end = _iso(day_data.get("peak_end"))
|
||||
if peak_start is not None:
|
||||
attrs["peak_start"] = peak_start
|
||||
if peak_end is not None:
|
||||
attrs["peak_end"] = peak_end
|
||||
|
||||
# Segments (list of monotone regions)
|
||||
segments = day_data.get("segments")
|
||||
if segments:
|
||||
attrs["segments"] = segments
|
||||
|
||||
return attrs or None
|
||||
|
|
|
|||
|
|
@ -113,3 +113,27 @@ class TibberPricesMetadataCalculator(TibberPricesBaseCalculator):
|
|||
return value.lower()
|
||||
|
||||
return value
|
||||
|
||||
def get_day_pattern_value(self, day: str) -> str | None:
|
||||
"""
|
||||
Get the detected price pattern for a calendar day.
|
||||
|
||||
Args:
|
||||
day: One of "yesterday", "today", or "tomorrow".
|
||||
|
||||
Returns:
|
||||
Pattern string (e.g. "valley", "peak", "flat") or None if not available.
|
||||
|
||||
"""
|
||||
if not self.coordinator.data:
|
||||
return None
|
||||
|
||||
day_patterns = self.coordinator.data.get("dayPatterns")
|
||||
if not day_patterns:
|
||||
return None
|
||||
|
||||
day_data = day_patterns.get(day)
|
||||
if not day_data:
|
||||
return None
|
||||
|
||||
return day_data.get("pattern")
|
||||
|
|
|
|||
|
|
@ -865,7 +865,70 @@ PEAK_PRICE_TIMING_SENSORS = (
|
|||
),
|
||||
)
|
||||
|
||||
# 8. DIAGNOSTIC SENSORS (data availability and metadata)
|
||||
# 8. DAY PATTERN SENSORS (price shape classification per calendar day)
|
||||
# ----------------------------------------------------------------------------
|
||||
|
||||
DAY_PATTERN_SENSORS = (
|
||||
SensorEntityDescription(
|
||||
key="day_pattern_yesterday",
|
||||
translation_key="day_pattern_yesterday",
|
||||
icon="mdi:chart-bell-curve",
|
||||
device_class=SensorDeviceClass.ENUM,
|
||||
options=[
|
||||
"valley",
|
||||
"peak",
|
||||
"double_valley",
|
||||
"double_peak",
|
||||
"flat",
|
||||
"rising",
|
||||
"falling",
|
||||
"mixed",
|
||||
],
|
||||
state_class=None,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="day_pattern_today",
|
||||
translation_key="day_pattern_today",
|
||||
icon="mdi:chart-bell-curve",
|
||||
device_class=SensorDeviceClass.ENUM,
|
||||
options=[
|
||||
"valley",
|
||||
"peak",
|
||||
"double_valley",
|
||||
"double_peak",
|
||||
"flat",
|
||||
"rising",
|
||||
"falling",
|
||||
"mixed",
|
||||
],
|
||||
state_class=None,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=True,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="day_pattern_tomorrow",
|
||||
translation_key="day_pattern_tomorrow",
|
||||
icon="mdi:chart-bell-curve",
|
||||
device_class=SensorDeviceClass.ENUM,
|
||||
options=[
|
||||
"valley",
|
||||
"peak",
|
||||
"double_valley",
|
||||
"double_peak",
|
||||
"flat",
|
||||
"rising",
|
||||
"falling",
|
||||
"mixed",
|
||||
],
|
||||
state_class=None,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
)
|
||||
|
||||
# 9. DIAGNOSTIC SENSORS (data availability and metadata)
|
||||
# ----------------------------------------------------------------------------
|
||||
|
||||
DIAGNOSTIC_SENSORS = (
|
||||
|
|
@ -1055,5 +1118,6 @@ ENTITY_DESCRIPTIONS = (
|
|||
*VOLATILITY_SENSORS,
|
||||
*BEST_PRICE_TIMING_SENSORS,
|
||||
*PEAK_PRICE_TIMING_SENSORS,
|
||||
*DAY_PATTERN_SENSORS,
|
||||
*DIAGNOSTIC_SENSORS,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -238,6 +238,10 @@ def get_value_getter_mapping( # noqa: PLR0913 - needs all calculators as parame
|
|||
),
|
||||
# Subscription sensors (via MetadataCalculator)
|
||||
"subscription_status": lambda: metadata_calculator.get_subscription_value("status"),
|
||||
# Day pattern sensors (via MetadataCalculator)
|
||||
"day_pattern_yesterday": lambda: metadata_calculator.get_day_pattern_value("yesterday"),
|
||||
"day_pattern_today": lambda: metadata_calculator.get_day_pattern_value("today"),
|
||||
"day_pattern_tomorrow": lambda: metadata_calculator.get_day_pattern_value("tomorrow"),
|
||||
# Volatility sensors (via VolatilityCalculator)
|
||||
"today_volatility": lambda: volatility_calculator.get_volatility_value(volatility_type="today"),
|
||||
"tomorrow_volatility": lambda: volatility_calculator.get_volatility_value(volatility_type="tomorrow"),
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
get_price:
|
||||
fields:
|
||||
entry_id:
|
||||
required: true
|
||||
required: false
|
||||
example: "1234567890abcdef"
|
||||
selector:
|
||||
config_entry:
|
||||
|
|
@ -19,7 +19,7 @@ get_price:
|
|||
get_apexcharts_yaml:
|
||||
fields:
|
||||
entry_id:
|
||||
required: true
|
||||
required: false
|
||||
example: "1234567890abcdef"
|
||||
selector:
|
||||
config_entry:
|
||||
|
|
@ -73,7 +73,7 @@ get_chartdata:
|
|||
general:
|
||||
fields:
|
||||
entry_id:
|
||||
required: true
|
||||
required: false
|
||||
example: "1234567890abcdef"
|
||||
selector:
|
||||
config_entry:
|
||||
|
|
@ -274,12 +274,772 @@ get_chartdata:
|
|||
refresh_user_data:
|
||||
fields:
|
||||
entry_id:
|
||||
required: true
|
||||
required: false
|
||||
example: "1234567890abcdef"
|
||||
selector:
|
||||
config_entry:
|
||||
integration: tibber_prices
|
||||
|
||||
find_cheapest_block:
|
||||
fields:
|
||||
entry_id:
|
||||
required: false
|
||||
example: "1234567890abcdef"
|
||||
selector:
|
||||
config_entry:
|
||||
integration: tibber_prices
|
||||
duration:
|
||||
required: true
|
||||
selector:
|
||||
duration:
|
||||
search_range:
|
||||
fields:
|
||||
search_scope:
|
||||
required: false
|
||||
selector:
|
||||
select:
|
||||
options:
|
||||
- today
|
||||
- tomorrow
|
||||
- remaining_today
|
||||
- next_24h
|
||||
- next_48h
|
||||
translation_key: search_scope
|
||||
search_start:
|
||||
required: false
|
||||
example: "2026-04-11T06:00:00+02:00"
|
||||
selector:
|
||||
datetime:
|
||||
search_end:
|
||||
required: false
|
||||
example: "2026-04-12T00:00:00+02:00"
|
||||
selector:
|
||||
datetime:
|
||||
time_alternatives:
|
||||
collapsed: true
|
||||
fields:
|
||||
search_start_time:
|
||||
required: false
|
||||
example: "06:00:00"
|
||||
selector:
|
||||
time:
|
||||
search_start_day_offset:
|
||||
required: false
|
||||
default: 0
|
||||
selector:
|
||||
number:
|
||||
min: -7
|
||||
max: 2
|
||||
mode: box
|
||||
search_end_time:
|
||||
required: false
|
||||
example: "23:00:00"
|
||||
selector:
|
||||
time:
|
||||
search_end_day_offset:
|
||||
required: false
|
||||
default: 0
|
||||
selector:
|
||||
number:
|
||||
min: -7
|
||||
max: 2
|
||||
mode: box
|
||||
search_start_offset_minutes:
|
||||
required: false
|
||||
example: 60
|
||||
selector:
|
||||
number:
|
||||
min: -10080
|
||||
max: 10080
|
||||
unit_of_measurement: min
|
||||
mode: box
|
||||
search_end_offset_minutes:
|
||||
required: false
|
||||
example: 480
|
||||
selector:
|
||||
number:
|
||||
min: -10080
|
||||
max: 10080
|
||||
unit_of_measurement: min
|
||||
mode: box
|
||||
include_current_interval:
|
||||
required: false
|
||||
default: true
|
||||
selector:
|
||||
boolean:
|
||||
price_filter:
|
||||
collapsed: true
|
||||
fields:
|
||||
max_price_level:
|
||||
required: false
|
||||
selector:
|
||||
select:
|
||||
options:
|
||||
- very_cheap
|
||||
- cheap
|
||||
- normal
|
||||
- expensive
|
||||
- very_expensive
|
||||
translation_key: level_filter
|
||||
min_price_level:
|
||||
required: false
|
||||
selector:
|
||||
select:
|
||||
options:
|
||||
- very_cheap
|
||||
- cheap
|
||||
- normal
|
||||
- expensive
|
||||
- very_expensive
|
||||
translation_key: level_filter
|
||||
output:
|
||||
collapsed: true
|
||||
fields:
|
||||
power_profile:
|
||||
required: false
|
||||
selector:
|
||||
object:
|
||||
include_comparison_details:
|
||||
required: false
|
||||
default: false
|
||||
selector:
|
||||
boolean:
|
||||
use_base_unit:
|
||||
required: false
|
||||
default: false
|
||||
selector:
|
||||
boolean:
|
||||
|
||||
find_most_expensive_block:
|
||||
fields:
|
||||
entry_id:
|
||||
required: false
|
||||
example: "1234567890abcdef"
|
||||
selector:
|
||||
config_entry:
|
||||
integration: tibber_prices
|
||||
duration:
|
||||
required: true
|
||||
selector:
|
||||
duration:
|
||||
search_range:
|
||||
fields:
|
||||
search_scope:
|
||||
required: false
|
||||
selector:
|
||||
select:
|
||||
options:
|
||||
- today
|
||||
- tomorrow
|
||||
- remaining_today
|
||||
- next_24h
|
||||
- next_48h
|
||||
translation_key: search_scope
|
||||
search_start:
|
||||
required: false
|
||||
example: "2026-04-11T06:00:00+02:00"
|
||||
selector:
|
||||
datetime:
|
||||
search_end:
|
||||
required: false
|
||||
example: "2026-04-12T00:00:00+02:00"
|
||||
selector:
|
||||
datetime:
|
||||
time_alternatives:
|
||||
collapsed: true
|
||||
fields:
|
||||
search_start_time:
|
||||
required: false
|
||||
example: "06:00:00"
|
||||
selector:
|
||||
time:
|
||||
search_start_day_offset:
|
||||
required: false
|
||||
default: 0
|
||||
selector:
|
||||
number:
|
||||
min: -7
|
||||
max: 2
|
||||
mode: box
|
||||
search_end_time:
|
||||
required: false
|
||||
example: "23:00:00"
|
||||
selector:
|
||||
time:
|
||||
search_end_day_offset:
|
||||
required: false
|
||||
default: 0
|
||||
selector:
|
||||
number:
|
||||
min: -7
|
||||
max: 2
|
||||
mode: box
|
||||
search_start_offset_minutes:
|
||||
required: false
|
||||
example: 60
|
||||
selector:
|
||||
number:
|
||||
min: -10080
|
||||
max: 10080
|
||||
unit_of_measurement: min
|
||||
mode: box
|
||||
search_end_offset_minutes:
|
||||
required: false
|
||||
example: 480
|
||||
selector:
|
||||
number:
|
||||
min: -10080
|
||||
max: 10080
|
||||
unit_of_measurement: min
|
||||
mode: box
|
||||
include_current_interval:
|
||||
required: false
|
||||
default: true
|
||||
selector:
|
||||
boolean:
|
||||
price_filter:
|
||||
collapsed: true
|
||||
fields:
|
||||
max_price_level:
|
||||
required: false
|
||||
selector:
|
||||
select:
|
||||
options:
|
||||
- very_cheap
|
||||
- cheap
|
||||
- normal
|
||||
- expensive
|
||||
- very_expensive
|
||||
translation_key: level_filter
|
||||
min_price_level:
|
||||
required: false
|
||||
selector:
|
||||
select:
|
||||
options:
|
||||
- very_cheap
|
||||
- cheap
|
||||
- normal
|
||||
- expensive
|
||||
- very_expensive
|
||||
translation_key: level_filter
|
||||
output:
|
||||
collapsed: true
|
||||
fields:
|
||||
power_profile:
|
||||
required: false
|
||||
selector:
|
||||
object:
|
||||
include_comparison_details:
|
||||
required: false
|
||||
default: false
|
||||
selector:
|
||||
boolean:
|
||||
use_base_unit:
|
||||
required: false
|
||||
default: false
|
||||
selector:
|
||||
boolean:
|
||||
|
||||
find_cheapest_hours:
|
||||
fields:
|
||||
entry_id:
|
||||
required: false
|
||||
example: "1234567890abcdef"
|
||||
selector:
|
||||
config_entry:
|
||||
integration: tibber_prices
|
||||
duration:
|
||||
required: true
|
||||
selector:
|
||||
duration:
|
||||
min_segment_duration:
|
||||
required: false
|
||||
selector:
|
||||
duration:
|
||||
search_range:
|
||||
fields:
|
||||
search_scope:
|
||||
required: false
|
||||
selector:
|
||||
select:
|
||||
options:
|
||||
- today
|
||||
- tomorrow
|
||||
- remaining_today
|
||||
- next_24h
|
||||
- next_48h
|
||||
translation_key: search_scope
|
||||
search_start:
|
||||
required: false
|
||||
example: "2026-04-11T06:00:00+02:00"
|
||||
selector:
|
||||
datetime:
|
||||
search_end:
|
||||
required: false
|
||||
example: "2026-04-12T00:00:00+02:00"
|
||||
selector:
|
||||
datetime:
|
||||
time_alternatives:
|
||||
collapsed: true
|
||||
fields:
|
||||
search_start_time:
|
||||
required: false
|
||||
example: "06:00:00"
|
||||
selector:
|
||||
time:
|
||||
search_start_day_offset:
|
||||
required: false
|
||||
default: 0
|
||||
selector:
|
||||
number:
|
||||
min: -7
|
||||
max: 2
|
||||
mode: box
|
||||
search_end_time:
|
||||
required: false
|
||||
example: "23:00:00"
|
||||
selector:
|
||||
time:
|
||||
search_end_day_offset:
|
||||
required: false
|
||||
default: 0
|
||||
selector:
|
||||
number:
|
||||
min: -7
|
||||
max: 2
|
||||
mode: box
|
||||
search_start_offset_minutes:
|
||||
required: false
|
||||
example: 60
|
||||
selector:
|
||||
number:
|
||||
min: -10080
|
||||
max: 10080
|
||||
unit_of_measurement: min
|
||||
mode: box
|
||||
search_end_offset_minutes:
|
||||
required: false
|
||||
example: 480
|
||||
selector:
|
||||
number:
|
||||
min: -10080
|
||||
max: 10080
|
||||
unit_of_measurement: min
|
||||
mode: box
|
||||
include_current_interval:
|
||||
required: false
|
||||
default: true
|
||||
selector:
|
||||
boolean:
|
||||
price_filter:
|
||||
collapsed: true
|
||||
fields:
|
||||
max_price_level:
|
||||
required: false
|
||||
selector:
|
||||
select:
|
||||
options:
|
||||
- very_cheap
|
||||
- cheap
|
||||
- normal
|
||||
- expensive
|
||||
- very_expensive
|
||||
translation_key: level_filter
|
||||
min_price_level:
|
||||
required: false
|
||||
selector:
|
||||
select:
|
||||
options:
|
||||
- very_cheap
|
||||
- cheap
|
||||
- normal
|
||||
- expensive
|
||||
- very_expensive
|
||||
translation_key: level_filter
|
||||
output:
|
||||
collapsed: true
|
||||
fields:
|
||||
power_profile:
|
||||
required: false
|
||||
selector:
|
||||
object:
|
||||
include_comparison_details:
|
||||
required: false
|
||||
default: false
|
||||
selector:
|
||||
boolean:
|
||||
use_base_unit:
|
||||
required: false
|
||||
default: false
|
||||
selector:
|
||||
boolean:
|
||||
|
||||
find_most_expensive_hours:
|
||||
fields:
|
||||
entry_id:
|
||||
required: false
|
||||
example: "1234567890abcdef"
|
||||
selector:
|
||||
config_entry:
|
||||
integration: tibber_prices
|
||||
duration:
|
||||
required: true
|
||||
selector:
|
||||
duration:
|
||||
min_segment_duration:
|
||||
required: false
|
||||
selector:
|
||||
duration:
|
||||
search_range:
|
||||
fields:
|
||||
search_scope:
|
||||
required: false
|
||||
selector:
|
||||
select:
|
||||
options:
|
||||
- today
|
||||
- tomorrow
|
||||
- remaining_today
|
||||
- next_24h
|
||||
- next_48h
|
||||
translation_key: search_scope
|
||||
search_start:
|
||||
required: false
|
||||
example: "2026-04-11T06:00:00+02:00"
|
||||
selector:
|
||||
datetime:
|
||||
search_end:
|
||||
required: false
|
||||
example: "2026-04-12T00:00:00+02:00"
|
||||
selector:
|
||||
datetime:
|
||||
time_alternatives:
|
||||
collapsed: true
|
||||
fields:
|
||||
search_start_time:
|
||||
required: false
|
||||
example: "06:00:00"
|
||||
selector:
|
||||
time:
|
||||
search_start_day_offset:
|
||||
required: false
|
||||
default: 0
|
||||
selector:
|
||||
number:
|
||||
min: -7
|
||||
max: 2
|
||||
mode: box
|
||||
search_end_time:
|
||||
required: false
|
||||
example: "23:00:00"
|
||||
selector:
|
||||
time:
|
||||
search_end_day_offset:
|
||||
required: false
|
||||
default: 0
|
||||
selector:
|
||||
number:
|
||||
min: -7
|
||||
max: 2
|
||||
mode: box
|
||||
search_start_offset_minutes:
|
||||
required: false
|
||||
example: 60
|
||||
selector:
|
||||
number:
|
||||
min: -10080
|
||||
max: 10080
|
||||
unit_of_measurement: min
|
||||
mode: box
|
||||
search_end_offset_minutes:
|
||||
required: false
|
||||
example: 480
|
||||
selector:
|
||||
number:
|
||||
min: -10080
|
||||
max: 10080
|
||||
unit_of_measurement: min
|
||||
mode: box
|
||||
include_current_interval:
|
||||
required: false
|
||||
default: true
|
||||
selector:
|
||||
boolean:
|
||||
price_filter:
|
||||
collapsed: true
|
||||
fields:
|
||||
max_price_level:
|
||||
required: false
|
||||
selector:
|
||||
select:
|
||||
options:
|
||||
- very_cheap
|
||||
- cheap
|
||||
- normal
|
||||
- expensive
|
||||
- very_expensive
|
||||
translation_key: level_filter
|
||||
min_price_level:
|
||||
required: false
|
||||
selector:
|
||||
select:
|
||||
options:
|
||||
- very_cheap
|
||||
- cheap
|
||||
- normal
|
||||
- expensive
|
||||
- very_expensive
|
||||
translation_key: level_filter
|
||||
output:
|
||||
collapsed: true
|
||||
fields:
|
||||
power_profile:
|
||||
required: false
|
||||
selector:
|
||||
object:
|
||||
include_comparison_details:
|
||||
required: false
|
||||
default: false
|
||||
selector:
|
||||
boolean:
|
||||
use_base_unit:
|
||||
required: false
|
||||
default: false
|
||||
selector:
|
||||
boolean:
|
||||
|
||||
find_cheapest_schedule:
|
||||
fields:
|
||||
entry_id:
|
||||
required: false
|
||||
example: "1234567890abcdef"
|
||||
selector:
|
||||
config_entry:
|
||||
integration: tibber_prices
|
||||
tasks:
|
||||
required: true
|
||||
example: '[{"name": "dishwasher", "duration": "02:00:00"}, {"name": "washing_machine", "duration": "01:30:00"}]'
|
||||
selector:
|
||||
object:
|
||||
scheduling_options:
|
||||
fields:
|
||||
gap_minutes:
|
||||
required: false
|
||||
default: 0
|
||||
selector:
|
||||
number:
|
||||
min: 0
|
||||
max: 120
|
||||
unit_of_measurement: min
|
||||
mode: box
|
||||
search_range:
|
||||
fields:
|
||||
search_scope:
|
||||
required: false
|
||||
selector:
|
||||
select:
|
||||
options:
|
||||
- today
|
||||
- tomorrow
|
||||
- remaining_today
|
||||
- next_24h
|
||||
- next_48h
|
||||
translation_key: search_scope
|
||||
search_start:
|
||||
required: false
|
||||
example: "2026-04-11T06:00:00+02:00"
|
||||
selector:
|
||||
datetime:
|
||||
search_end:
|
||||
required: false
|
||||
example: "2026-04-12T00:00:00+02:00"
|
||||
selector:
|
||||
datetime:
|
||||
time_alternatives:
|
||||
collapsed: true
|
||||
fields:
|
||||
search_start_time:
|
||||
required: false
|
||||
example: "06:00:00"
|
||||
selector:
|
||||
time:
|
||||
search_start_day_offset:
|
||||
required: false
|
||||
default: 0
|
||||
selector:
|
||||
number:
|
||||
min: -7
|
||||
max: 2
|
||||
mode: box
|
||||
search_end_time:
|
||||
required: false
|
||||
example: "23:00:00"
|
||||
selector:
|
||||
time:
|
||||
search_end_day_offset:
|
||||
required: false
|
||||
default: 0
|
||||
selector:
|
||||
number:
|
||||
min: -7
|
||||
max: 2
|
||||
mode: box
|
||||
search_start_offset_minutes:
|
||||
required: false
|
||||
example: 60
|
||||
selector:
|
||||
number:
|
||||
min: -10080
|
||||
max: 10080
|
||||
unit_of_measurement: min
|
||||
mode: box
|
||||
search_end_offset_minutes:
|
||||
required: false
|
||||
example: 480
|
||||
selector:
|
||||
number:
|
||||
min: -10080
|
||||
max: 10080
|
||||
unit_of_measurement: min
|
||||
mode: box
|
||||
price_filter:
|
||||
collapsed: true
|
||||
fields:
|
||||
max_price_level:
|
||||
required: false
|
||||
selector:
|
||||
select:
|
||||
options:
|
||||
- very_cheap
|
||||
- cheap
|
||||
- normal
|
||||
- expensive
|
||||
- very_expensive
|
||||
translation_key: level_filter
|
||||
min_price_level:
|
||||
required: false
|
||||
selector:
|
||||
select:
|
||||
options:
|
||||
- very_cheap
|
||||
- cheap
|
||||
- normal
|
||||
- expensive
|
||||
- very_expensive
|
||||
translation_key: level_filter
|
||||
output:
|
||||
collapsed: true
|
||||
fields:
|
||||
use_base_unit:
|
||||
required: false
|
||||
default: false
|
||||
selector:
|
||||
boolean:
|
||||
|
||||
|
||||
search_start:
|
||||
required: false
|
||||
example: "2026-04-11T06:00:00+02:00"
|
||||
selector:
|
||||
datetime:
|
||||
search_end:
|
||||
required: false
|
||||
example: "2026-04-12T00:00:00+02:00"
|
||||
selector:
|
||||
datetime:
|
||||
search_start_time:
|
||||
required: false
|
||||
example: "06:00:00"
|
||||
selector:
|
||||
time:
|
||||
search_start_day_offset:
|
||||
required: false
|
||||
default: 0
|
||||
selector:
|
||||
number:
|
||||
min: -7
|
||||
max: 2
|
||||
mode: box
|
||||
search_end_time:
|
||||
required: false
|
||||
example: "23:00:00"
|
||||
selector:
|
||||
time:
|
||||
search_end_day_offset:
|
||||
required: false
|
||||
default: 0
|
||||
selector:
|
||||
number:
|
||||
min: -7
|
||||
max: 2
|
||||
mode: box
|
||||
search_start_offset_minutes:
|
||||
required: false
|
||||
example: 60
|
||||
selector:
|
||||
number:
|
||||
min: -10080
|
||||
max: 10080
|
||||
unit_of_measurement: min
|
||||
mode: box
|
||||
search_end_offset_minutes:
|
||||
required: false
|
||||
example: 480
|
||||
selector:
|
||||
number:
|
||||
min: -10080
|
||||
max: 10080
|
||||
unit_of_measurement: min
|
||||
mode: box
|
||||
search_scope:
|
||||
required: false
|
||||
selector:
|
||||
select:
|
||||
options:
|
||||
- today
|
||||
- tomorrow
|
||||
- remaining_today
|
||||
- next_24h
|
||||
- next_48h
|
||||
max_price_level:
|
||||
required: false
|
||||
selector:
|
||||
select:
|
||||
options:
|
||||
- very_cheap
|
||||
- cheap
|
||||
- normal
|
||||
- expensive
|
||||
- very_expensive
|
||||
min_price_level:
|
||||
required: false
|
||||
selector:
|
||||
select:
|
||||
options:
|
||||
- very_cheap
|
||||
- cheap
|
||||
- normal
|
||||
- expensive
|
||||
- very_expensive
|
||||
include_comparison_details:
|
||||
required: false
|
||||
default: false
|
||||
selector:
|
||||
boolean:
|
||||
power_profile:
|
||||
required: false
|
||||
selector:
|
||||
object:
|
||||
include_current_interval:
|
||||
required: false
|
||||
default: true
|
||||
selector:
|
||||
boolean:
|
||||
use_base_unit:
|
||||
required: false
|
||||
default: false
|
||||
selector:
|
||||
boolean:
|
||||
debug_clear_tomorrow:
|
||||
fields:
|
||||
entry_id:
|
||||
|
|
|
|||
|
|
@ -25,6 +25,31 @@ from typing import TYPE_CHECKING
|
|||
from custom_components.tibber_prices.const import DOMAIN
|
||||
from homeassistant.core import SupportsResponse, callback
|
||||
|
||||
from .find_cheapest_block import (
|
||||
FIND_CHEAPEST_BLOCK_SERVICE_NAME,
|
||||
FIND_CHEAPEST_BLOCK_SERVICE_SCHEMA,
|
||||
handle_find_cheapest_block,
|
||||
)
|
||||
from .find_cheapest_hours import (
|
||||
FIND_CHEAPEST_HOURS_SERVICE_NAME,
|
||||
FIND_CHEAPEST_HOURS_SERVICE_SCHEMA,
|
||||
handle_find_cheapest_hours,
|
||||
)
|
||||
from .find_cheapest_schedule import (
|
||||
FIND_CHEAPEST_SCHEDULE_SERVICE_NAME,
|
||||
FIND_CHEAPEST_SCHEDULE_SERVICE_SCHEMA,
|
||||
handle_find_cheapest_schedule,
|
||||
)
|
||||
from .find_most_expensive_block import (
|
||||
FIND_MOST_EXPENSIVE_BLOCK_SERVICE_NAME,
|
||||
FIND_MOST_EXPENSIVE_BLOCK_SERVICE_SCHEMA,
|
||||
handle_find_most_expensive_block,
|
||||
)
|
||||
from .find_most_expensive_hours import (
|
||||
FIND_MOST_EXPENSIVE_HOURS_SERVICE_NAME,
|
||||
FIND_MOST_EXPENSIVE_HOURS_SERVICE_SCHEMA,
|
||||
handle_find_most_expensive_hours,
|
||||
)
|
||||
from .get_apexcharts_yaml import (
|
||||
APEXCHARTS_SERVICE_SCHEMA,
|
||||
APEXCHARTS_YAML_SERVICE_NAME,
|
||||
|
|
@ -73,6 +98,41 @@ def async_setup_services(hass: HomeAssistant) -> None:
|
|||
schema=GET_PRICE_SERVICE_SCHEMA,
|
||||
supports_response=SupportsResponse.ONLY,
|
||||
)
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
FIND_CHEAPEST_BLOCK_SERVICE_NAME,
|
||||
handle_find_cheapest_block,
|
||||
schema=FIND_CHEAPEST_BLOCK_SERVICE_SCHEMA,
|
||||
supports_response=SupportsResponse.ONLY,
|
||||
)
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
FIND_CHEAPEST_HOURS_SERVICE_NAME,
|
||||
handle_find_cheapest_hours,
|
||||
schema=FIND_CHEAPEST_HOURS_SERVICE_SCHEMA,
|
||||
supports_response=SupportsResponse.ONLY,
|
||||
)
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
FIND_CHEAPEST_SCHEDULE_SERVICE_NAME,
|
||||
handle_find_cheapest_schedule,
|
||||
schema=FIND_CHEAPEST_SCHEDULE_SERVICE_SCHEMA,
|
||||
supports_response=SupportsResponse.ONLY,
|
||||
)
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
FIND_MOST_EXPENSIVE_BLOCK_SERVICE_NAME,
|
||||
handle_find_most_expensive_block,
|
||||
schema=FIND_MOST_EXPENSIVE_BLOCK_SERVICE_SCHEMA,
|
||||
supports_response=SupportsResponse.ONLY,
|
||||
)
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
FIND_MOST_EXPENSIVE_HOURS_SERVICE_NAME,
|
||||
handle_find_most_expensive_hours,
|
||||
schema=FIND_MOST_EXPENSIVE_HOURS_SERVICE_SCHEMA,
|
||||
supports_response=SupportsResponse.ONLY,
|
||||
)
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
REFRESH_USER_DATA_SERVICE_NAME,
|
||||
|
|
|
|||
296
custom_components/tibber_prices/services/find_cheapest_block.py
Normal file
296
custom_components/tibber_prices/services/find_cheapest_block.py
Normal file
|
|
@ -0,0 +1,296 @@
|
|||
"""
|
||||
Service handler for find_cheapest_block and find_most_expensive_block services.
|
||||
|
||||
Finds the cheapest (or most expensive) contiguous window of a given duration
|
||||
within a search range. Designed for appliance scheduling (dishwasher, washing
|
||||
machine, dryer).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import math
|
||||
from datetime import datetime, timedelta
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from custom_components.tibber_prices.const import (
|
||||
DOMAIN,
|
||||
get_display_unit_factor,
|
||||
get_display_unit_string,
|
||||
)
|
||||
from custom_components.tibber_prices.utils.price_window import (
|
||||
calculate_window_statistics,
|
||||
find_cheapest_contiguous_window,
|
||||
)
|
||||
from homeassistant.exceptions import ServiceValidationError
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.util import dt as dt_utils
|
||||
|
||||
from .helpers import (
|
||||
INTERVAL_MINUTES,
|
||||
PRICE_LEVEL_ORDER,
|
||||
VALID_SEARCH_SCOPES,
|
||||
build_rating_lookup,
|
||||
build_response_interval,
|
||||
filter_intervals_by_price_level,
|
||||
get_entry_and_data,
|
||||
resolve_home_timezone,
|
||||
resolve_search_range,
|
||||
validate_power_profile_length,
|
||||
validate_price_level_range,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from homeassistant.core import HomeAssistant, ServiceCall, ServiceResponse
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
FIND_CHEAPEST_BLOCK_SERVICE_NAME = "find_cheapest_block"
|
||||
|
||||
_COMMON_BLOCK_SCHEMA = {
|
||||
vol.Optional("entry_id", default=""): cv.string,
|
||||
vol.Required("duration"): vol.All(
|
||||
cv.positive_time_period,
|
||||
vol.Range(min=timedelta(minutes=1), max=timedelta(hours=12)),
|
||||
),
|
||||
vol.Optional("search_start"): cv.datetime,
|
||||
vol.Optional("search_end"): cv.datetime,
|
||||
vol.Optional("search_start_time"): cv.time,
|
||||
vol.Optional("search_start_day_offset", default=0): vol.All(vol.Coerce(int), vol.Range(min=-7, max=2)),
|
||||
vol.Optional("search_end_time"): cv.time,
|
||||
vol.Optional("search_end_day_offset", default=0): vol.All(vol.Coerce(int), vol.Range(min=-7, max=2)),
|
||||
vol.Optional("search_start_offset_minutes"): vol.All(vol.Coerce(int), vol.Range(min=-10080, max=10080)),
|
||||
vol.Optional("search_end_offset_minutes"): vol.All(vol.Coerce(int), vol.Range(min=-10080, max=10080)),
|
||||
vol.Optional("search_scope"): vol.In(VALID_SEARCH_SCOPES),
|
||||
vol.Optional("max_price_level"): vol.In([lvl.lower() for lvl in PRICE_LEVEL_ORDER]),
|
||||
vol.Optional("min_price_level"): vol.In([lvl.lower() for lvl in PRICE_LEVEL_ORDER]),
|
||||
vol.Optional("include_comparison_details", default=False): cv.boolean,
|
||||
vol.Optional("power_profile"): vol.All(
|
||||
[vol.All(vol.Coerce(int), vol.Range(min=1, max=100000))],
|
||||
vol.Length(min=1, max=48),
|
||||
),
|
||||
vol.Optional("include_current_interval", default=True): cv.boolean,
|
||||
vol.Optional("use_base_unit", default=False): cv.boolean,
|
||||
}
|
||||
|
||||
FIND_CHEAPEST_BLOCK_SERVICE_SCHEMA = vol.Schema(_COMMON_BLOCK_SCHEMA)
|
||||
|
||||
|
||||
def _compute_price_comparison(
|
||||
comparison_result: dict | None,
|
||||
unit_factor: int,
|
||||
stats: dict,
|
||||
*,
|
||||
reverse: bool,
|
||||
include_details: bool = False,
|
||||
) -> dict[str, float | str | None] | None:
|
||||
"""Compute price comparison between the selected and opposite-direction window."""
|
||||
if comparison_result is None:
|
||||
return None
|
||||
|
||||
comparison_stats = calculate_window_statistics(
|
||||
comparison_result["intervals"], unit_factor=unit_factor, round_decimals=4
|
||||
)
|
||||
if stats.get("price_mean") is None or comparison_stats.get("price_mean") is None:
|
||||
return None
|
||||
|
||||
diff = round(comparison_stats["price_mean"] - stats["price_mean"], 4)
|
||||
if reverse:
|
||||
diff = -diff
|
||||
|
||||
result: dict[str, float | str | None] = {
|
||||
"comparison_price_mean": comparison_stats["price_mean"],
|
||||
"price_difference": abs(diff),
|
||||
"comparison_window_start": (
|
||||
comparison_result["intervals"][0]["startsAt"]
|
||||
if isinstance(comparison_result["intervals"][0]["startsAt"], str)
|
||||
else comparison_result["intervals"][0]["startsAt"].isoformat()
|
||||
),
|
||||
}
|
||||
|
||||
# Optional enrichment (P6)
|
||||
if include_details:
|
||||
result["comparison_price_min"] = comparison_stats.get("price_min")
|
||||
result["comparison_price_max"] = comparison_stats.get("price_max")
|
||||
last_start = comparison_result["intervals"][-1]["startsAt"]
|
||||
if not isinstance(last_start, str):
|
||||
last_start = last_start.isoformat()
|
||||
result["comparison_window_end"] = (
|
||||
datetime.fromisoformat(last_start) + timedelta(minutes=INTERVAL_MINUTES)
|
||||
).isoformat()
|
||||
|
||||
return result
|
||||
|
||||
|
||||
async def _handle_find_block( # noqa: PLR0915
|
||||
call: ServiceCall,
|
||||
*,
|
||||
reverse: bool = False,
|
||||
) -> ServiceResponse:
|
||||
"""
|
||||
Core handler for finding price blocks (cheapest or most expensive).
|
||||
|
||||
Finds the cheapest/most expensive contiguous window of the requested
|
||||
duration within the search range using a sliding window algorithm.
|
||||
"""
|
||||
service_label = "find_most_expensive_block" if reverse else "find_cheapest_block"
|
||||
hass: HomeAssistant = call.hass
|
||||
entry_id: str = call.data.get("entry_id", "")
|
||||
duration_td: timedelta = call.data["duration"]
|
||||
use_base_unit: bool = call.data.get("use_base_unit", False)
|
||||
max_price_level: str | None = call.data.get("max_price_level")
|
||||
min_price_level: str | None = call.data.get("min_price_level")
|
||||
include_comparison_details: bool = call.data.get("include_comparison_details", False)
|
||||
power_profile: list[int] | None = call.data.get("power_profile")
|
||||
|
||||
duration_minutes_requested = int(duration_td.total_seconds() / 60)
|
||||
# Round up to nearest quarter-hour interval
|
||||
duration_minutes = math.ceil(duration_minutes_requested / INTERVAL_MINUTES) * INTERVAL_MINUTES
|
||||
|
||||
entry, coordinator, data = get_entry_and_data(hass, entry_id)
|
||||
rating_lookup = build_rating_lookup(data)
|
||||
|
||||
home_id = entry.data.get("home_id")
|
||||
if not home_id:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="missing_home_id",
|
||||
)
|
||||
|
||||
# Resolve timezone
|
||||
home_timezone = resolve_home_timezone(coordinator, home_id)
|
||||
home_tz: ZoneInfo
|
||||
from zoneinfo import ZoneInfo # noqa: PLC0415
|
||||
|
||||
home_tz = ZoneInfo(home_timezone)
|
||||
|
||||
# Resolve search range (priority: explicit datetime > time+offset > minutes offset > default)
|
||||
now = dt_utils.now().astimezone(home_tz)
|
||||
search_start, search_end = resolve_search_range(call.data, now, home_tz)
|
||||
|
||||
duration_intervals = duration_minutes // INTERVAL_MINUTES
|
||||
|
||||
# Validate parameter combinations
|
||||
validate_price_level_range(min_price_level, max_price_level)
|
||||
validate_power_profile_length(power_profile, duration_intervals)
|
||||
|
||||
_LOGGER.info(
|
||||
"%s called: duration=%dmin, range=%s to %s",
|
||||
service_label,
|
||||
duration_minutes,
|
||||
search_start,
|
||||
search_end,
|
||||
)
|
||||
|
||||
# Fetch intervals via pool
|
||||
api_client = coordinator.api
|
||||
user_data = coordinator._cached_user_data # noqa: SLF001
|
||||
pool = entry.runtime_data.interval_pool
|
||||
|
||||
try:
|
||||
price_info, _api_called = await pool.get_intervals(
|
||||
api_client=api_client,
|
||||
user_data=user_data,
|
||||
start_time=search_start,
|
||||
end_time=search_end,
|
||||
)
|
||||
except Exception as error:
|
||||
_LOGGER.exception("Error fetching price data for %s", service_label)
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="price_fetch_failed",
|
||||
) from error
|
||||
|
||||
# Determine currency and unit
|
||||
currency = entry.data.get("currency", "EUR")
|
||||
unit_factor = 1 if use_base_unit else get_display_unit_factor(entry)
|
||||
price_unit = f"{currency}/kWh" if use_base_unit else get_display_unit_string(entry, currency)
|
||||
|
||||
# Apply optional price level filter (P5)
|
||||
filtered_price_info = filter_intervals_by_price_level(price_info, min_price_level, max_price_level)
|
||||
|
||||
# Find cheapest/most expensive window
|
||||
result = find_cheapest_contiguous_window(filtered_price_info, duration_intervals, reverse=reverse)
|
||||
|
||||
if result is None:
|
||||
_LOGGER.info(
|
||||
"%s: no window found (need %d intervals, have %d after level filter)",
|
||||
service_label,
|
||||
duration_intervals,
|
||||
len(filtered_price_info),
|
||||
)
|
||||
return {
|
||||
"home_id": home_id,
|
||||
"search_start": search_start.isoformat(),
|
||||
"search_end": search_end.isoformat(),
|
||||
"duration_minutes_requested": duration_minutes_requested,
|
||||
"duration_minutes": duration_minutes,
|
||||
"currency": currency,
|
||||
"price_unit": price_unit,
|
||||
"window_found": False,
|
||||
"window": None,
|
||||
}
|
||||
|
||||
# Find the opposite-direction window for price comparison (from full unfiltered list)
|
||||
comparison_result = find_cheapest_contiguous_window(price_info, duration_intervals, reverse=not reverse)
|
||||
|
||||
# Calculate statistics and build response
|
||||
stats = calculate_window_statistics(
|
||||
result["intervals"], unit_factor=unit_factor, round_decimals=4, power_profile=power_profile
|
||||
)
|
||||
|
||||
# Calculate price comparison (difference to opposite-direction window)
|
||||
price_comparison = _compute_price_comparison(
|
||||
comparison_result, unit_factor, stats, reverse=reverse, include_details=include_comparison_details
|
||||
)
|
||||
|
||||
# Build interval list with converted prices
|
||||
response_intervals = [build_response_interval(iv, unit_factor, rating_lookup) for iv in result["intervals"]]
|
||||
|
||||
# Calculate end time (last interval start + 15 min)
|
||||
last_start = result["intervals"][-1]["startsAt"]
|
||||
if isinstance(last_start, str):
|
||||
end_time = datetime.fromisoformat(last_start) + timedelta(minutes=INTERVAL_MINUTES)
|
||||
else:
|
||||
end_time = last_start + timedelta(minutes=INTERVAL_MINUTES)
|
||||
|
||||
response = {
|
||||
"home_id": home_id,
|
||||
"search_start": search_start.isoformat(),
|
||||
"search_end": search_end.isoformat(),
|
||||
"duration_minutes_requested": duration_minutes_requested,
|
||||
"duration_minutes": duration_minutes,
|
||||
"currency": currency,
|
||||
"price_unit": price_unit,
|
||||
"window_found": True,
|
||||
"window": {
|
||||
"start": result["intervals"][0]["startsAt"]
|
||||
if isinstance(result["intervals"][0]["startsAt"], str)
|
||||
else result["intervals"][0]["startsAt"].isoformat(),
|
||||
"end": end_time.isoformat() if hasattr(end_time, "isoformat") else end_time,
|
||||
"duration_minutes": duration_minutes,
|
||||
"interval_count": len(result["intervals"]),
|
||||
**stats,
|
||||
"intervals": response_intervals,
|
||||
},
|
||||
"price_comparison": price_comparison or None,
|
||||
}
|
||||
|
||||
_LOGGER.info(
|
||||
"%s: found window at %s, mean=%.4f %s",
|
||||
service_label,
|
||||
response["window"]["start"],
|
||||
stats.get("price_mean", 0) or 0,
|
||||
price_unit,
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
|
||||
async def handle_find_cheapest_block(call: ServiceCall) -> ServiceResponse:
|
||||
"""Handle find_cheapest_block service call."""
|
||||
return await _handle_find_block(call, reverse=False)
|
||||
334
custom_components/tibber_prices/services/find_cheapest_hours.py
Normal file
334
custom_components/tibber_prices/services/find_cheapest_hours.py
Normal file
|
|
@ -0,0 +1,334 @@
|
|||
"""
|
||||
Service handler for find_cheapest_hours and find_most_expensive_hours services.
|
||||
|
||||
Finds the cheapest (or most expensive) N minutes of intervals within a search range.
|
||||
Intervals need not be contiguous — designed for flexible loads
|
||||
(battery charging, EV, water heater with thermostat).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import math
|
||||
from datetime import datetime, timedelta
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from custom_components.tibber_prices.const import (
|
||||
DOMAIN,
|
||||
get_display_unit_factor,
|
||||
get_display_unit_string,
|
||||
)
|
||||
from custom_components.tibber_prices.utils.price_window import (
|
||||
calculate_window_statistics,
|
||||
find_cheapest_n_intervals,
|
||||
)
|
||||
from homeassistant.exceptions import ServiceValidationError
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.util import dt as dt_utils
|
||||
|
||||
from .helpers import (
|
||||
INTERVAL_MINUTES,
|
||||
PRICE_LEVEL_ORDER,
|
||||
VALID_SEARCH_SCOPES,
|
||||
build_rating_lookup,
|
||||
build_response_interval,
|
||||
filter_intervals_by_price_level,
|
||||
get_entry_and_data,
|
||||
resolve_home_timezone,
|
||||
resolve_search_range,
|
||||
validate_power_profile_length,
|
||||
validate_price_level_range,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from homeassistant.core import HomeAssistant, ServiceCall, ServiceResponse
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
FIND_CHEAPEST_HOURS_SERVICE_NAME = "find_cheapest_hours"
|
||||
|
||||
_COMMON_HOURS_SCHEMA = {
|
||||
vol.Optional("entry_id", default=""): cv.string,
|
||||
vol.Required("duration"): vol.All(
|
||||
cv.positive_time_period,
|
||||
vol.Range(min=timedelta(minutes=1), max=timedelta(hours=24)),
|
||||
),
|
||||
vol.Optional("search_start"): cv.datetime,
|
||||
vol.Optional("search_end"): cv.datetime,
|
||||
vol.Optional("search_start_time"): cv.time,
|
||||
vol.Optional("search_start_day_offset", default=0): vol.All(vol.Coerce(int), vol.Range(min=-7, max=2)),
|
||||
vol.Optional("search_end_time"): cv.time,
|
||||
vol.Optional("search_end_day_offset", default=0): vol.All(vol.Coerce(int), vol.Range(min=-7, max=2)),
|
||||
vol.Optional("search_start_offset_minutes"): vol.All(vol.Coerce(int), vol.Range(min=-10080, max=10080)),
|
||||
vol.Optional("search_end_offset_minutes"): vol.All(vol.Coerce(int), vol.Range(min=-10080, max=10080)),
|
||||
vol.Optional("min_segment_duration"): vol.All(
|
||||
cv.positive_time_period,
|
||||
vol.Range(min=timedelta(minutes=1), max=timedelta(hours=4)),
|
||||
),
|
||||
vol.Optional("search_scope"): vol.In(VALID_SEARCH_SCOPES),
|
||||
vol.Optional("max_price_level"): vol.In([lvl.lower() for lvl in PRICE_LEVEL_ORDER]),
|
||||
vol.Optional("min_price_level"): vol.In([lvl.lower() for lvl in PRICE_LEVEL_ORDER]),
|
||||
vol.Optional("include_comparison_details", default=False): cv.boolean,
|
||||
vol.Optional("power_profile"): vol.All(
|
||||
[vol.All(vol.Coerce(int), vol.Range(min=1, max=100000))],
|
||||
vol.Length(min=1, max=96),
|
||||
),
|
||||
vol.Optional("include_current_interval", default=True): cv.boolean,
|
||||
vol.Optional("use_base_unit", default=False): cv.boolean,
|
||||
}
|
||||
|
||||
FIND_CHEAPEST_HOURS_SERVICE_SCHEMA = vol.Schema(_COMMON_HOURS_SCHEMA)
|
||||
|
||||
|
||||
def _build_found_response( # noqa: PLR0913
|
||||
*,
|
||||
result: dict,
|
||||
comparison_result: dict | None,
|
||||
reverse: bool,
|
||||
home_id: str,
|
||||
search_start: datetime,
|
||||
search_end: datetime,
|
||||
total_minutes_requested: int,
|
||||
total_minutes: int,
|
||||
min_segment_minutes_requested: int,
|
||||
min_segment_minutes: int,
|
||||
currency: str,
|
||||
price_unit: str,
|
||||
unit_factor: int,
|
||||
service_label: str,
|
||||
rating_lookup: dict[str, str | None],
|
||||
include_comparison_details: bool = False,
|
||||
power_profile: list[int] | None = None,
|
||||
) -> dict:
|
||||
"""Build the service response when intervals are found."""
|
||||
stats = calculate_window_statistics(
|
||||
result["intervals"], unit_factor=unit_factor, round_decimals=4, power_profile=power_profile
|
||||
)
|
||||
|
||||
# Calculate price comparison (difference to opposite-direction selection)
|
||||
price_comparison: dict[str, float | str | None] = {}
|
||||
if comparison_result is not None:
|
||||
comparison_stats = calculate_window_statistics(
|
||||
comparison_result["intervals"], unit_factor=unit_factor, round_decimals=4
|
||||
)
|
||||
own_mean = stats.get("price_mean")
|
||||
comp_mean = comparison_stats.get("price_mean")
|
||||
if own_mean is not None and comp_mean is not None:
|
||||
diff = round(float(comp_mean) - float(own_mean), 4)
|
||||
if reverse:
|
||||
diff = -diff
|
||||
price_comparison = {
|
||||
"comparison_price_mean": comp_mean,
|
||||
"price_difference": abs(round(diff, 4)),
|
||||
}
|
||||
if include_comparison_details:
|
||||
price_comparison["comparison_price_min"] = comparison_stats.get("price_min")
|
||||
price_comparison["comparison_price_max"] = comparison_stats.get("price_max")
|
||||
|
||||
response_intervals = [build_response_interval(iv, unit_factor, rating_lookup) for iv in result["intervals"]]
|
||||
|
||||
response_segments = []
|
||||
for seg in result["segments"]:
|
||||
seg_stats = calculate_window_statistics(seg["intervals"], unit_factor=unit_factor, round_decimals=4)
|
||||
last_start = seg["intervals"][-1]["startsAt"]
|
||||
if isinstance(last_start, str):
|
||||
seg_end = datetime.fromisoformat(last_start) + timedelta(minutes=INTERVAL_MINUTES)
|
||||
else:
|
||||
seg_end = last_start + timedelta(minutes=INTERVAL_MINUTES)
|
||||
|
||||
response_segments.append(
|
||||
{
|
||||
"start": seg["start"],
|
||||
"end": seg_end.isoformat() if hasattr(seg_end, "isoformat") else seg_end,
|
||||
"duration_minutes": seg["duration_minutes"],
|
||||
"interval_count": seg["interval_count"],
|
||||
"price_mean": seg_stats.get("price_mean"),
|
||||
"intervals": [build_response_interval(iv, unit_factor, rating_lookup) for iv in seg["intervals"]],
|
||||
}
|
||||
)
|
||||
|
||||
actual_minutes = len(result["intervals"]) * INTERVAL_MINUTES
|
||||
|
||||
_LOGGER.info(
|
||||
"%s: found %d intervals in %d segments, mean=%.4f %s",
|
||||
service_label,
|
||||
len(result["intervals"]),
|
||||
len(response_segments),
|
||||
stats.get("price_mean", 0) or 0,
|
||||
price_unit,
|
||||
)
|
||||
|
||||
return {
|
||||
"home_id": home_id,
|
||||
"search_start": search_start.isoformat(),
|
||||
"search_end": search_end.isoformat(),
|
||||
"total_minutes_requested": total_minutes_requested,
|
||||
"total_minutes": total_minutes,
|
||||
"min_segment_minutes_requested": min_segment_minutes_requested,
|
||||
"min_segment_minutes": min_segment_minutes,
|
||||
"currency": currency,
|
||||
"price_unit": price_unit,
|
||||
"intervals_found": True,
|
||||
"schedule": {
|
||||
"total_minutes": actual_minutes,
|
||||
"interval_count": len(result["intervals"]),
|
||||
**stats,
|
||||
"segment_count": len(response_segments),
|
||||
"segments": response_segments,
|
||||
"intervals": response_intervals,
|
||||
},
|
||||
"price_comparison": price_comparison or None,
|
||||
}
|
||||
|
||||
|
||||
async def _handle_find_hours(
|
||||
call: ServiceCall,
|
||||
*,
|
||||
reverse: bool = False,
|
||||
) -> ServiceResponse:
|
||||
"""
|
||||
Core handler for finding price hours (cheapest or most expensive).
|
||||
|
||||
Finds the cheapest/most expensive N intervals (not necessarily contiguous)
|
||||
within the search range. Results are grouped into contiguous segments for
|
||||
scheduling convenience.
|
||||
"""
|
||||
service_label = "find_most_expensive_hours" if reverse else "find_cheapest_hours"
|
||||
hass: HomeAssistant = call.hass
|
||||
entry_id: str = call.data.get("entry_id", "")
|
||||
duration_td: timedelta = call.data["duration"]
|
||||
min_segment_td: timedelta | None = call.data.get("min_segment_duration")
|
||||
use_base_unit: bool = call.data.get("use_base_unit", False)
|
||||
max_price_level: str | None = call.data.get("max_price_level")
|
||||
min_price_level: str | None = call.data.get("min_price_level")
|
||||
include_comparison_details: bool = call.data.get("include_comparison_details", False)
|
||||
power_profile: list[int] | None = call.data.get("power_profile")
|
||||
|
||||
total_minutes_requested = int(duration_td.total_seconds() / 60)
|
||||
min_segment_minutes_requested = int(min_segment_td.total_seconds() / 60) if min_segment_td else INTERVAL_MINUTES
|
||||
|
||||
# Round up to nearest quarter-hour intervals
|
||||
total_minutes = math.ceil(total_minutes_requested / INTERVAL_MINUTES) * INTERVAL_MINUTES
|
||||
min_segment_minutes = math.ceil(min_segment_minutes_requested / INTERVAL_MINUTES) * INTERVAL_MINUTES
|
||||
|
||||
entry, coordinator, data = get_entry_and_data(hass, entry_id)
|
||||
rating_lookup = build_rating_lookup(data)
|
||||
|
||||
home_id = entry.data.get("home_id")
|
||||
if not home_id:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="missing_home_id",
|
||||
)
|
||||
|
||||
# Resolve timezone
|
||||
home_timezone = resolve_home_timezone(coordinator, home_id)
|
||||
home_tz: ZoneInfo
|
||||
from zoneinfo import ZoneInfo # noqa: PLC0415
|
||||
|
||||
home_tz = ZoneInfo(home_timezone)
|
||||
|
||||
# Resolve search range (priority: explicit datetime > time+offset > minutes offset > default)
|
||||
now = dt_utils.now().astimezone(home_tz)
|
||||
search_start, search_end = resolve_search_range(call.data, now, home_tz)
|
||||
|
||||
total_intervals = total_minutes // INTERVAL_MINUTES
|
||||
min_segment_intervals = min_segment_minutes // INTERVAL_MINUTES
|
||||
|
||||
# Validate parameter combinations
|
||||
validate_price_level_range(min_price_level, max_price_level)
|
||||
validate_power_profile_length(power_profile, total_intervals)
|
||||
|
||||
_LOGGER.info(
|
||||
"%s called: total=%dmin, min_segment=%dmin, range=%s to %s",
|
||||
service_label,
|
||||
total_minutes,
|
||||
min_segment_minutes,
|
||||
search_start,
|
||||
search_end,
|
||||
)
|
||||
|
||||
# Fetch intervals via pool
|
||||
api_client = coordinator.api
|
||||
user_data = coordinator._cached_user_data # noqa: SLF001
|
||||
pool = entry.runtime_data.interval_pool
|
||||
|
||||
try:
|
||||
price_info, _api_called = await pool.get_intervals(
|
||||
api_client=api_client,
|
||||
user_data=user_data,
|
||||
start_time=search_start,
|
||||
end_time=search_end,
|
||||
)
|
||||
except Exception as error:
|
||||
_LOGGER.exception("Error fetching price data for %s", service_label)
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="price_fetch_failed",
|
||||
) from error
|
||||
|
||||
# Determine currency and unit
|
||||
currency = entry.data.get("currency", "EUR")
|
||||
unit_factor = 1 if use_base_unit else get_display_unit_factor(entry)
|
||||
price_unit = f"{currency}/kWh" if use_base_unit else get_display_unit_string(entry, currency)
|
||||
|
||||
# Apply optional price level filter (P5)
|
||||
filtered_price_info = filter_intervals_by_price_level(price_info, min_price_level, max_price_level)
|
||||
|
||||
# Find cheapest/most expensive intervals
|
||||
result = find_cheapest_n_intervals(filtered_price_info, total_intervals, min_segment_intervals, reverse=reverse)
|
||||
|
||||
if result is None:
|
||||
_LOGGER.info(
|
||||
"%s: not enough intervals (need %d, have %d after level filter)",
|
||||
service_label,
|
||||
total_intervals,
|
||||
len(filtered_price_info),
|
||||
)
|
||||
return {
|
||||
"home_id": home_id,
|
||||
"search_start": search_start.isoformat(),
|
||||
"search_end": search_end.isoformat(),
|
||||
"total_minutes_requested": total_minutes_requested,
|
||||
"total_minutes": total_minutes,
|
||||
"min_segment_minutes_requested": min_segment_minutes_requested,
|
||||
"min_segment_minutes": min_segment_minutes,
|
||||
"currency": currency,
|
||||
"price_unit": price_unit,
|
||||
"intervals_found": False,
|
||||
"schedule": None,
|
||||
}
|
||||
|
||||
# Find opposite-direction selection for price comparison (from full unfiltered list)
|
||||
comparison_result = find_cheapest_n_intervals(
|
||||
price_info, total_intervals, min_segment_intervals, reverse=not reverse
|
||||
)
|
||||
|
||||
return _build_found_response(
|
||||
result=result,
|
||||
comparison_result=comparison_result,
|
||||
reverse=reverse,
|
||||
home_id=home_id,
|
||||
search_start=search_start,
|
||||
search_end=search_end,
|
||||
total_minutes_requested=total_minutes_requested,
|
||||
total_minutes=total_minutes,
|
||||
min_segment_minutes_requested=min_segment_minutes_requested,
|
||||
min_segment_minutes=min_segment_minutes,
|
||||
currency=currency,
|
||||
price_unit=price_unit,
|
||||
unit_factor=unit_factor,
|
||||
service_label=service_label,
|
||||
rating_lookup=rating_lookup,
|
||||
include_comparison_details=include_comparison_details,
|
||||
power_profile=power_profile,
|
||||
)
|
||||
|
||||
|
||||
async def handle_find_cheapest_hours(call: ServiceCall) -> ServiceResponse:
|
||||
"""Handle find_cheapest_hours service call."""
|
||||
return await _handle_find_hours(call, reverse=False)
|
||||
|
|
@ -0,0 +1,331 @@
|
|||
"""
|
||||
Service handler for find_cheapest_schedule service.
|
||||
|
||||
Finds optimal non-overlapping blocks for multiple tasks within a search range.
|
||||
Uses a greedy algorithm: tasks are sorted by duration (longest first), then
|
||||
each task claims the cheapest available contiguous window in the remaining pool.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import math
|
||||
from datetime import datetime, timedelta
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from custom_components.tibber_prices.const import (
|
||||
DOMAIN,
|
||||
get_display_unit_factor,
|
||||
get_display_unit_string,
|
||||
)
|
||||
from custom_components.tibber_prices.utils.price_window import (
|
||||
calculate_window_statistics,
|
||||
)
|
||||
from homeassistant.exceptions import ServiceValidationError
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.util import dt as dt_utils
|
||||
|
||||
from .helpers import (
|
||||
INTERVAL_MINUTES,
|
||||
PRICE_LEVEL_ORDER,
|
||||
VALID_SEARCH_SCOPES,
|
||||
build_rating_lookup,
|
||||
build_response_interval,
|
||||
filter_intervals_by_price_level,
|
||||
get_entry_and_data,
|
||||
resolve_home_timezone,
|
||||
resolve_search_range,
|
||||
validate_power_profile_length,
|
||||
validate_price_level_range,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from homeassistant.core import HomeAssistant, ServiceCall, ServiceResponse
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
FIND_CHEAPEST_SCHEDULE_SERVICE_NAME = "find_cheapest_schedule"
|
||||
|
||||
_TASK_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required("name"): cv.string,
|
||||
vol.Required("duration"): vol.All(
|
||||
cv.positive_time_period,
|
||||
vol.Range(min=timedelta(minutes=1), max=timedelta(hours=12)),
|
||||
),
|
||||
vol.Optional("power_profile"): vol.All(
|
||||
[vol.All(vol.Coerce(int), vol.Range(min=1, max=100000))],
|
||||
vol.Length(min=1, max=48),
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
FIND_CHEAPEST_SCHEDULE_SERVICE_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Optional("entry_id", default=""): cv.string,
|
||||
vol.Required("tasks"): vol.All(
|
||||
[_TASK_SCHEMA],
|
||||
vol.Length(min=1, max=4),
|
||||
),
|
||||
vol.Optional("gap_minutes", default=0): vol.All(vol.Coerce(int), vol.Range(min=0, max=120)),
|
||||
vol.Optional("search_start"): cv.datetime,
|
||||
vol.Optional("search_end"): cv.datetime,
|
||||
vol.Optional("search_start_time"): cv.time,
|
||||
vol.Optional("search_start_day_offset", default=0): vol.All(vol.Coerce(int), vol.Range(min=-7, max=2)),
|
||||
vol.Optional("search_end_time"): cv.time,
|
||||
vol.Optional("search_end_day_offset", default=0): vol.All(vol.Coerce(int), vol.Range(min=-7, max=2)),
|
||||
vol.Optional("search_start_offset_minutes"): vol.All(vol.Coerce(int), vol.Range(min=-10080, max=10080)),
|
||||
vol.Optional("search_end_offset_minutes"): vol.All(vol.Coerce(int), vol.Range(min=-10080, max=10080)),
|
||||
vol.Optional("search_scope"): vol.In(VALID_SEARCH_SCOPES),
|
||||
vol.Optional("max_price_level"): vol.In([lvl.lower() for lvl in PRICE_LEVEL_ORDER]),
|
||||
vol.Optional("min_price_level"): vol.In([lvl.lower() for lvl in PRICE_LEVEL_ORDER]),
|
||||
vol.Optional("use_base_unit", default=False): cv.boolean,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _find_cheapest_window_in_pool(
|
||||
pool: list[dict[str, Any]],
|
||||
duration_intervals: int,
|
||||
available: list[bool],
|
||||
) -> tuple[int, int] | None:
|
||||
"""
|
||||
Find the cheapest contiguous window of `duration_intervals` in available pool slots.
|
||||
|
||||
Args:
|
||||
pool: Full sorted interval list.
|
||||
duration_intervals: Required contiguous count.
|
||||
available: Boolean mask, same length as pool. True = still available.
|
||||
|
||||
Returns:
|
||||
(start_index, end_index_exclusive) of the best window, or None if not found.
|
||||
|
||||
"""
|
||||
n = len(pool)
|
||||
best_sum: float | None = None
|
||||
best_start: int = -1
|
||||
|
||||
i = 0
|
||||
while i <= n - duration_intervals:
|
||||
# Check if a contiguous block starting at i is fully available
|
||||
# and all intervals are contiguous in time (no gaps)
|
||||
block: list[dict[str, Any]] = []
|
||||
j = i
|
||||
while j < n and len(block) < duration_intervals:
|
||||
if not available[j]:
|
||||
break
|
||||
if block:
|
||||
# Check temporal contiguity
|
||||
prev_start = block[-1]["startsAt"]
|
||||
curr_start = pool[j]["startsAt"]
|
||||
prev_dt = datetime.fromisoformat(prev_start) if isinstance(prev_start, str) else prev_start
|
||||
curr_dt = datetime.fromisoformat(curr_start) if isinstance(curr_start, str) else curr_start
|
||||
if curr_dt - prev_dt != timedelta(minutes=INTERVAL_MINUTES):
|
||||
# Gap in time — can't extend this block, skip to j+1
|
||||
break
|
||||
block.append(pool[j])
|
||||
j += 1
|
||||
|
||||
if len(block) == duration_intervals:
|
||||
window_sum = sum(iv["total"] for iv in block)
|
||||
if best_sum is None or window_sum < best_sum:
|
||||
best_sum = window_sum
|
||||
best_start = i
|
||||
i += 1
|
||||
else:
|
||||
# Skip past the blocking unavailable/non-contiguous slot
|
||||
i = j + 1
|
||||
|
||||
if best_start == -1:
|
||||
return None
|
||||
|
||||
return (best_start, best_start + duration_intervals)
|
||||
|
||||
|
||||
async def handle_find_cheapest_schedule(call: ServiceCall) -> ServiceResponse: # noqa: PLR0915
|
||||
"""Handle find_cheapest_schedule service call."""
|
||||
service_label = "find_cheapest_schedule"
|
||||
hass: HomeAssistant = call.hass
|
||||
entry_id: str = call.data.get("entry_id", "")
|
||||
tasks_raw: list[dict[str, Any]] = call.data["tasks"]
|
||||
gap_minutes: int = call.data.get("gap_minutes", 0)
|
||||
use_base_unit: bool = call.data.get("use_base_unit", False)
|
||||
max_price_level: str | None = call.data.get("max_price_level")
|
||||
min_price_level: str | None = call.data.get("min_price_level")
|
||||
|
||||
# Round gap up to nearest quarter interval
|
||||
gap_intervals = math.ceil(gap_minutes / INTERVAL_MINUTES) if gap_minutes > 0 else 0
|
||||
|
||||
entry, coordinator, data = get_entry_and_data(hass, entry_id)
|
||||
rating_lookup = build_rating_lookup(data)
|
||||
|
||||
home_id = entry.data.get("home_id")
|
||||
if not home_id:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="missing_home_id",
|
||||
)
|
||||
|
||||
home_timezone = resolve_home_timezone(coordinator, home_id)
|
||||
home_tz: ZoneInfo
|
||||
from zoneinfo import ZoneInfo # noqa: PLC0415
|
||||
|
||||
home_tz = ZoneInfo(home_timezone)
|
||||
|
||||
now = dt_utils.now().astimezone(home_tz)
|
||||
search_start, search_end = resolve_search_range(call.data, now, home_tz)
|
||||
|
||||
# Resolve task durations (round up to intervals)
|
||||
tasks: list[dict[str, Any]] = []
|
||||
for task in tasks_raw:
|
||||
dur_td: timedelta = task["duration"]
|
||||
dur_minutes_req = int(dur_td.total_seconds() / 60)
|
||||
dur_minutes = math.ceil(dur_minutes_req / INTERVAL_MINUTES) * INTERVAL_MINUTES
|
||||
dur_intervals = dur_minutes // INTERVAL_MINUTES
|
||||
validate_power_profile_length(task.get("power_profile"), dur_intervals)
|
||||
tasks.append(
|
||||
{
|
||||
"name": task["name"],
|
||||
"duration_minutes_requested": dur_minutes_req,
|
||||
"duration_minutes": dur_minutes,
|
||||
"duration_intervals": dur_intervals,
|
||||
"power_profile": task.get("power_profile"),
|
||||
}
|
||||
)
|
||||
|
||||
# Validate parameter combinations
|
||||
validate_price_level_range(min_price_level, max_price_level)
|
||||
|
||||
_LOGGER.info(
|
||||
"%s called: %d tasks, gap=%dmin, range=%s to %s",
|
||||
service_label,
|
||||
len(tasks),
|
||||
gap_minutes,
|
||||
search_start,
|
||||
search_end,
|
||||
)
|
||||
|
||||
# Fetch intervals
|
||||
api_client = coordinator.api
|
||||
user_data = coordinator._cached_user_data # noqa: SLF001
|
||||
pool = entry.runtime_data.interval_pool
|
||||
|
||||
try:
|
||||
price_info, _api_called = await pool.get_intervals(
|
||||
api_client=api_client,
|
||||
user_data=user_data,
|
||||
start_time=search_start,
|
||||
end_time=search_end,
|
||||
)
|
||||
except Exception as error:
|
||||
_LOGGER.exception("Error fetching price data for %s", service_label)
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="price_fetch_failed",
|
||||
) from error
|
||||
|
||||
currency = entry.data.get("currency", "EUR")
|
||||
unit_factor = 1 if use_base_unit else get_display_unit_factor(entry)
|
||||
price_unit = f"{currency}/kWh" if use_base_unit else get_display_unit_string(entry, currency)
|
||||
|
||||
# Apply optional level filter
|
||||
filtered_price_info = filter_intervals_by_price_level(price_info, min_price_level, max_price_level)
|
||||
|
||||
if not filtered_price_info:
|
||||
return {
|
||||
"home_id": home_id,
|
||||
"search_start": search_start.isoformat(),
|
||||
"search_end": search_end.isoformat(),
|
||||
"currency": currency,
|
||||
"price_unit": price_unit,
|
||||
"all_tasks_scheduled": False,
|
||||
"tasks": [],
|
||||
"total_estimated_cost": None,
|
||||
}
|
||||
|
||||
# Greedy assignment: longest task first
|
||||
tasks_sorted = sorted(tasks, key=lambda t: t["duration_intervals"], reverse=True)
|
||||
available = [True] * len(filtered_price_info)
|
||||
assignments: list[dict[str, Any]] = []
|
||||
unscheduled: list[str] = []
|
||||
|
||||
for task in tasks_sorted:
|
||||
dur_intervals = task["duration_intervals"]
|
||||
window = _find_cheapest_window_in_pool(filtered_price_info, dur_intervals, available)
|
||||
|
||||
if window is None:
|
||||
_LOGGER.info("%s: no window found for task '%s'", service_label, task["name"])
|
||||
unscheduled.append(task["name"])
|
||||
continue
|
||||
|
||||
start_idx, end_idx = window
|
||||
task_intervals = filtered_price_info[start_idx:end_idx]
|
||||
|
||||
# Mark task intervals + trailing gap as unavailable
|
||||
gap_end = min(end_idx + gap_intervals, len(filtered_price_info))
|
||||
for k in range(start_idx, gap_end):
|
||||
available[k] = False
|
||||
|
||||
stats = calculate_window_statistics(
|
||||
task_intervals,
|
||||
unit_factor=unit_factor,
|
||||
round_decimals=4,
|
||||
power_profile=task.get("power_profile"),
|
||||
)
|
||||
|
||||
first_start = task_intervals[0]["startsAt"]
|
||||
last_start = task_intervals[-1]["startsAt"]
|
||||
first_dt = datetime.fromisoformat(first_start) if isinstance(first_start, str) else first_start
|
||||
last_dt = datetime.fromisoformat(last_start) if isinstance(last_start, str) else last_start
|
||||
end_dt = last_dt + timedelta(minutes=INTERVAL_MINUTES)
|
||||
|
||||
# Build enriched interval list for this task
|
||||
task_response_intervals = [build_response_interval(iv, unit_factor, rating_lookup) for iv in task_intervals]
|
||||
|
||||
assignments.append(
|
||||
{
|
||||
"name": task["name"],
|
||||
"start": first_dt.isoformat(),
|
||||
"end": end_dt.isoformat(),
|
||||
"duration_minutes_requested": task["duration_minutes_requested"],
|
||||
"duration_minutes": task["duration_minutes"],
|
||||
**stats,
|
||||
"intervals": task_response_intervals,
|
||||
}
|
||||
)
|
||||
|
||||
# Sort final assignments by start time
|
||||
assignments.sort(key=lambda a: a["start"])
|
||||
|
||||
# Sum estimated costs
|
||||
total_cost_values: list[float] = [
|
||||
a["estimated_total_cost"] for a in assignments if a.get("estimated_total_cost") is not None
|
||||
]
|
||||
total_estimated_cost = round(sum(total_cost_values), 4) if total_cost_values else None
|
||||
|
||||
all_scheduled = len(unscheduled) == 0
|
||||
|
||||
_LOGGER.info(
|
||||
"%s: scheduled %d/%d tasks, total_cost=%s",
|
||||
service_label,
|
||||
len(assignments),
|
||||
len(tasks),
|
||||
total_estimated_cost,
|
||||
)
|
||||
|
||||
result: dict[str, Any] = {
|
||||
"home_id": home_id,
|
||||
"search_start": search_start.isoformat(),
|
||||
"search_end": search_end.isoformat(),
|
||||
"currency": currency,
|
||||
"price_unit": price_unit,
|
||||
"all_tasks_scheduled": all_scheduled,
|
||||
"unscheduled_tasks": unscheduled or None,
|
||||
"tasks": assignments,
|
||||
"total_estimated_cost": total_estimated_cost,
|
||||
}
|
||||
return result
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
"""
|
||||
Service handler for find_most_expensive_block service.
|
||||
|
||||
Finds the most expensive contiguous window of a given duration within a search range.
|
||||
Mirror of find_cheapest_block with reversed price selection.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from .find_cheapest_block import _COMMON_BLOCK_SCHEMA, _handle_find_block
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from homeassistant.core import ServiceCall, ServiceResponse
|
||||
|
||||
FIND_MOST_EXPENSIVE_BLOCK_SERVICE_NAME = "find_most_expensive_block"
|
||||
|
||||
FIND_MOST_EXPENSIVE_BLOCK_SERVICE_SCHEMA = vol.Schema(_COMMON_BLOCK_SCHEMA)
|
||||
|
||||
|
||||
async def handle_find_most_expensive_block(call: ServiceCall) -> ServiceResponse:
|
||||
"""Handle find_most_expensive_block service call."""
|
||||
return await _handle_find_block(call, reverse=True)
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
"""
|
||||
Service handler for find_most_expensive_hours service.
|
||||
|
||||
Finds the most expensive N minutes of intervals within a search range.
|
||||
Mirror of find_cheapest_hours with reversed price selection.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from .find_cheapest_hours import _COMMON_HOURS_SCHEMA, _handle_find_hours
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from homeassistant.core import ServiceCall, ServiceResponse
|
||||
|
||||
FIND_MOST_EXPENSIVE_HOURS_SERVICE_NAME = "find_most_expensive_hours"
|
||||
|
||||
FIND_MOST_EXPENSIVE_HOURS_SERVICE_SCHEMA = vol.Schema(_COMMON_HOURS_SCHEMA)
|
||||
|
||||
|
||||
async def handle_find_most_expensive_hours(call: ServiceCall) -> ServiceResponse:
|
||||
"""Handle find_most_expensive_hours service call."""
|
||||
return await _handle_find_hours(call, reverse=True)
|
||||
|
|
@ -25,7 +25,6 @@ import voluptuous as vol
|
|||
from custom_components.tibber_prices.const import (
|
||||
CONF_CURRENCY_DISPLAY_MODE,
|
||||
DISPLAY_MODE_SUBUNIT,
|
||||
DOMAIN,
|
||||
PRICE_LEVEL_CHEAP,
|
||||
PRICE_LEVEL_EXPENSIVE,
|
||||
PRICE_LEVEL_NORMAL,
|
||||
|
|
@ -37,7 +36,6 @@ from custom_components.tibber_prices.const import (
|
|||
get_display_unit_string,
|
||||
get_translation,
|
||||
)
|
||||
from homeassistant.exceptions import ServiceValidationError
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.entity_registry import (
|
||||
EntityRegistry,
|
||||
|
|
@ -60,7 +58,7 @@ ATTR_ENTRY_ID: Final = "entry_id"
|
|||
# Service schema
|
||||
APEXCHARTS_SERVICE_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(ATTR_ENTRY_ID): cv.string,
|
||||
vol.Optional(ATTR_ENTRY_ID, default=""): cv.string,
|
||||
vol.Optional("day"): vol.In(["yesterday", "today", "tomorrow", "rolling_window", "rolling_window_autozoom"]),
|
||||
vol.Optional("level_type", default="rating_level"): vol.In(["rating_level", "level"]),
|
||||
vol.Optional("resolution", default="interval"): vol.In(["interval", "hourly"]),
|
||||
|
|
@ -290,10 +288,7 @@ async def handle_apexcharts_yaml(call: ServiceCall) -> dict[str, Any]: # noqa:
|
|||
|
||||
"""
|
||||
hass = call.hass
|
||||
entry_id_raw = call.data.get(ATTR_ENTRY_ID)
|
||||
if entry_id_raw is None:
|
||||
raise ServiceValidationError(translation_domain=DOMAIN, translation_key="missing_entry_id")
|
||||
entry_id: str = str(entry_id_raw)
|
||||
entry_id_input: str = call.data.get(ATTR_ENTRY_ID, "")
|
||||
|
||||
day = call.data.get("day") # Can be None (rolling window mode)
|
||||
level_type = call.data.get("level_type", "rating_level")
|
||||
|
|
@ -305,7 +300,8 @@ async def handle_apexcharts_yaml(call: ServiceCall) -> dict[str, Any]: # noqa:
|
|||
user_language = hass.config.language or "en"
|
||||
|
||||
# Get coordinator to access price data (for currency) and config entry for display settings
|
||||
config_entry, coordinator, _ = get_entry_and_data(hass, entry_id)
|
||||
config_entry, coordinator, _ = get_entry_and_data(hass, entry_id_input)
|
||||
entry_id: str = config_entry.entry_id
|
||||
# Get currency from coordinator data
|
||||
currency = coordinator.data.get("currency", "EUR")
|
||||
|
||||
|
|
|
|||
|
|
@ -270,7 +270,7 @@ ATTR_ENTRY_ID: Final = "entry_id"
|
|||
# Service schema
|
||||
CHARTDATA_SERVICE_SCHEMA: Final = vol.Schema(
|
||||
{
|
||||
vol.Required(ATTR_ENTRY_ID): str,
|
||||
vol.Optional(ATTR_ENTRY_ID, default=""): str,
|
||||
vol.Optional(ATTR_DAY): vol.All(vol.Coerce(list), [vol.In(["yesterday", "today", "tomorrow"])]),
|
||||
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"]),
|
||||
|
|
@ -346,10 +346,7 @@ async def handle_chartdata(call: ServiceCall) -> dict[str, Any]: # noqa: PLR091
|
|||
|
||||
"""
|
||||
hass = call.hass
|
||||
entry_id_raw = call.data.get(ATTR_ENTRY_ID)
|
||||
if entry_id_raw is None:
|
||||
raise ServiceValidationError(translation_domain=DOMAIN, translation_key="missing_entry_id")
|
||||
entry_id: str = str(entry_id_raw)
|
||||
entry_id: str = call.data.get(ATTR_ENTRY_ID, "")
|
||||
|
||||
# Get coordinator to check data availability
|
||||
_, coordinator, _ = get_entry_and_data(hass, entry_id)
|
||||
|
|
@ -393,6 +390,39 @@ async def handle_chartdata(call: ServiceCall) -> dict[str, Any]: # noqa: PLR091
|
|||
level_filter = call.data.get("level_filter")
|
||||
rating_level_filter = call.data.get("rating_level_filter")
|
||||
|
||||
# --- Parameter dependency validation ---
|
||||
|
||||
# level_filter and rating_level_filter are mutually exclusive
|
||||
if level_filter and rating_level_filter:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="level_and_rating_filter_conflict",
|
||||
)
|
||||
|
||||
has_filter = bool(level_filter or rating_level_filter)
|
||||
|
||||
# insert_nulls modes "segments"/"all" require a level or rating filter
|
||||
if insert_nulls != "none" and not has_filter:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="insert_nulls_requires_filter",
|
||||
translation_placeholders={"mode": insert_nulls},
|
||||
)
|
||||
|
||||
# connect_segments requires insert_nulls="segments" (with a filter)
|
||||
if connect_segments and insert_nulls != "segments":
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="connect_segments_requires_segments_mode",
|
||||
)
|
||||
|
||||
# array_fields is only meaningful with array_of_arrays format
|
||||
if call.data.get("array_fields") and output_format != "array_of_arrays":
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="array_fields_requires_array_format",
|
||||
)
|
||||
|
||||
# === METADATA-ONLY MODE ===
|
||||
# Early return: calculate and return only metadata, skip all data processing
|
||||
if metadata == "only":
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ GET_PRICE_SERVICE_NAME = "get_price"
|
|||
|
||||
GET_PRICE_SERVICE_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required("entry_id"): cv.string,
|
||||
vol.Optional("entry_id", default=""): cv.string,
|
||||
vol.Required("start_time"): cv.datetime,
|
||||
vol.Required("end_time"): cv.datetime,
|
||||
}
|
||||
|
|
@ -70,7 +70,7 @@ async def handle_get_price(call: ServiceCall) -> ServiceResponse:
|
|||
|
||||
"""
|
||||
hass: HomeAssistant = call.hass
|
||||
entry_id: str = call.data["entry_id"]
|
||||
entry_id: str = call.data.get("entry_id", "")
|
||||
start_time: datetime = call.data["start_time"]
|
||||
end_time: datetime = call.data["end_time"]
|
||||
|
||||
|
|
@ -126,6 +126,13 @@ async def handle_get_price(call: ServiceCall) -> ServiceResponse:
|
|||
# Step 2: Convert to home timezone
|
||||
end_time = end_time.astimezone(home_tz)
|
||||
|
||||
# Validate: end must be after start
|
||||
if end_time <= start_time:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="end_before_start",
|
||||
)
|
||||
|
||||
_LOGGER.info(
|
||||
"get_price service called: entry_id=%s, home_id=%s, range=%s to %s",
|
||||
entry_id,
|
||||
|
|
|
|||
|
|
@ -2,48 +2,194 @@
|
|||
Shared utilities for service handlers.
|
||||
|
||||
This module provides common helper functions used across multiple service handlers,
|
||||
such as entry validation and data extraction.
|
||||
such as entry validation, data extraction, timezone resolution, and search range handling.
|
||||
|
||||
Functions:
|
||||
get_entry_and_data: Validate config entry and extract coordinator data
|
||||
has_tomorrow_data: Check if tomorrow's price data is available
|
||||
resolve_home_timezone: Extract home timezone from coordinator
|
||||
localize_to_home_tz: Localize datetime to Tibber home timezone
|
||||
calculate_end_of_tomorrow: Calculate end of tomorrow in home timezone
|
||||
floor_to_quarter_hour: Floor datetime to quarter-hour boundary
|
||||
resolve_search_range: Resolve search start/end from various input formats
|
||||
filter_intervals_by_price_level: Filter intervals by Tibber price level
|
||||
VALID_SEARCH_SCOPES: Set of valid search_scope shorthand values
|
||||
PRICE_LEVEL_ORDER: Ordered tuple of price levels (lowest to highest)
|
||||
|
||||
Used by:
|
||||
- services/chartdata.py: Chart data export service
|
||||
- services/apexcharts.py: ApexCharts YAML generation
|
||||
- services/refresh_user_data.py: User data refresh
|
||||
- services/find_cheapest_block.py: Block service (cheapest + most expensive)
|
||||
- services/find_cheapest_hours.py: Hours service (cheapest + most expensive)
|
||||
- services/find_most_expensive_block.py: Most expensive block wrapper
|
||||
- services/find_most_expensive_hours.py: Most expensive hours wrapper
|
||||
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import time as dt_time
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from custom_components.tibber_prices.const import DOMAIN
|
||||
from custom_components.tibber_prices.coordinator.helpers import get_intervals_for_day_offsets
|
||||
from homeassistant.exceptions import ServiceValidationError
|
||||
from homeassistant.util import dt as dt_utils
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from custom_components.tibber_prices.coordinator import TibberPricesDataUpdateCoordinator
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
# Interval duration in minutes (quarter-hourly resolution)
|
||||
INTERVAL_MINUTES = 15
|
||||
|
||||
# Valid scopes for the search_scope shorthand parameter
|
||||
VALID_SEARCH_SCOPES = frozenset({"today", "tomorrow", "remaining_today", "next_24h", "next_48h"})
|
||||
|
||||
# Price level hierarchy (lowest to highest)
|
||||
PRICE_LEVEL_ORDER = ("VERY_CHEAP", "CHEAP", "NORMAL", "EXPENSIVE", "VERY_EXPENSIVE")
|
||||
_PRICE_LEVEL_RANK: dict[str, int] = {lvl: i for i, lvl in enumerate(PRICE_LEVEL_ORDER)}
|
||||
|
||||
|
||||
# Parameters that define explicit search range boundaries
|
||||
_EXPLICIT_RANGE_PARAMS = frozenset(
|
||||
{
|
||||
"search_start",
|
||||
"search_end",
|
||||
"search_start_time",
|
||||
"search_end_time",
|
||||
"search_start_offset_minutes",
|
||||
"search_end_offset_minutes",
|
||||
"search_start_day_offset",
|
||||
"search_end_day_offset",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def validate_search_params(call_data: dict[str, Any]) -> None:
|
||||
"""
|
||||
Validate search range parameter combinations.
|
||||
|
||||
Checks for mutually exclusive parameters and required co-dependencies.
|
||||
Must be called before resolve_search_range().
|
||||
|
||||
Raises:
|
||||
ServiceValidationError: If parameter combinations are invalid
|
||||
|
||||
"""
|
||||
has_scope = "search_scope" in call_data
|
||||
|
||||
# search_scope conflicts with all explicit range parameters
|
||||
if has_scope:
|
||||
# day_offset params always appear (voluptuous defaults to 0), exclude from conflict check
|
||||
conflicts = _EXPLICIT_RANGE_PARAMS - {"search_start_day_offset", "search_end_day_offset"}
|
||||
conflicting = [p for p in conflicts if p in call_data]
|
||||
if conflicting:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="scope_conflicts_with_range",
|
||||
translation_placeholders={"params": ", ".join(sorted(conflicting))},
|
||||
)
|
||||
|
||||
# day_offset without matching time parameter is meaningless
|
||||
# Schema defaults provide 0, but user explicitly setting non-zero without time is an error.
|
||||
# We detect explicit usage by checking for non-default values when time is absent.
|
||||
if "search_start_time" not in call_data and call_data.get("search_start_day_offset", 0) != 0:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="day_offset_requires_time",
|
||||
translation_placeholders={"offset_param": "search_start_day_offset", "time_param": "search_start_time"},
|
||||
)
|
||||
if "search_end_time" not in call_data and call_data.get("search_end_day_offset", 0) != 0:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="day_offset_requires_time",
|
||||
translation_placeholders={"offset_param": "search_end_day_offset", "time_param": "search_end_time"},
|
||||
)
|
||||
|
||||
|
||||
def validate_price_level_range(
|
||||
min_price_level: str | None,
|
||||
max_price_level: str | None,
|
||||
) -> None:
|
||||
"""
|
||||
Validate that min_price_level <= max_price_level in the level hierarchy.
|
||||
|
||||
Raises:
|
||||
ServiceValidationError: If min level is higher than max level
|
||||
|
||||
"""
|
||||
if min_price_level is None or max_price_level is None:
|
||||
return
|
||||
|
||||
min_rank = _PRICE_LEVEL_RANK.get(min_price_level.upper(), 0)
|
||||
max_rank = _PRICE_LEVEL_RANK.get(max_price_level.upper(), len(PRICE_LEVEL_ORDER) - 1)
|
||||
|
||||
if min_rank > max_rank:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="min_level_exceeds_max",
|
||||
translation_placeholders={"min_level": min_price_level, "max_level": max_price_level},
|
||||
)
|
||||
|
||||
|
||||
def validate_power_profile_length(
|
||||
power_profile: list[int] | None,
|
||||
duration_intervals: int,
|
||||
) -> None:
|
||||
"""
|
||||
Validate that power_profile length matches the number of intervals.
|
||||
|
||||
Raises:
|
||||
ServiceValidationError: If lengths don't match
|
||||
|
||||
"""
|
||||
if power_profile is None:
|
||||
return
|
||||
|
||||
if len(power_profile) != duration_intervals:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="power_profile_length_mismatch",
|
||||
translation_placeholders={
|
||||
"profile_length": str(len(power_profile)),
|
||||
"interval_count": str(duration_intervals),
|
||||
"duration_minutes": str(duration_intervals * INTERVAL_MINUTES),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def get_entry_and_data(hass: HomeAssistant, entry_id: str) -> tuple[Any, Any, dict]:
|
||||
"""
|
||||
Validate entry and extract coordinator and data.
|
||||
|
||||
If entry_id is empty, auto-selects the single config entry for this domain.
|
||||
Raises an error if there are zero or multiple entries and no entry_id is given.
|
||||
|
||||
Args:
|
||||
hass: Home Assistant instance
|
||||
entry_id: Config entry ID to validate
|
||||
entry_id: Config entry ID to validate (empty string to auto-select)
|
||||
|
||||
Returns:
|
||||
Tuple of (entry, coordinator, data)
|
||||
|
||||
Raises:
|
||||
ServiceValidationError: If entry_id is missing or invalid
|
||||
ServiceValidationError: If entry cannot be resolved
|
||||
|
||||
"""
|
||||
if not entry_id:
|
||||
raise ServiceValidationError(translation_domain=DOMAIN, translation_key="missing_entry_id")
|
||||
entries = hass.config_entries.async_entries(DOMAIN)
|
||||
if len(entries) == 1:
|
||||
entry = entries[0]
|
||||
elif len(entries) == 0:
|
||||
raise ServiceValidationError(translation_domain=DOMAIN, translation_key="no_entries_found")
|
||||
else:
|
||||
raise ServiceValidationError(translation_domain=DOMAIN, translation_key="multiple_entries_no_entry_id")
|
||||
else:
|
||||
entry = next(
|
||||
(e for e in hass.config_entries.async_entries(DOMAIN) if e.entry_id == entry_id),
|
||||
None,
|
||||
|
|
@ -72,3 +218,276 @@ def has_tomorrow_data(coordinator: TibberPricesDataUpdateCoordinator) -> bool:
|
|||
coordinator_data = coordinator.data or {}
|
||||
tomorrow_intervals = get_intervals_for_day_offsets(coordinator_data, [1])
|
||||
return len(tomorrow_intervals) > 0
|
||||
|
||||
|
||||
def resolve_home_timezone(
|
||||
coordinator: Any,
|
||||
home_id: str,
|
||||
) -> str:
|
||||
"""Extract home timezone from coordinator's cached user data."""
|
||||
user_data = coordinator._cached_user_data # noqa: SLF001
|
||||
if not user_data:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="user_data_not_available",
|
||||
)
|
||||
|
||||
if "viewer" in user_data:
|
||||
for home in user_data["viewer"].get("homes", []):
|
||||
if home.get("id") == home_id:
|
||||
tz = home.get("timeZone")
|
||||
if tz:
|
||||
return tz
|
||||
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="timezone_not_found",
|
||||
)
|
||||
|
||||
|
||||
def localize_to_home_tz(dt_value: datetime, home_tz: ZoneInfo) -> datetime:
|
||||
"""
|
||||
Localize a datetime to the Tibber home timezone.
|
||||
|
||||
Handles the critical two-step process:
|
||||
1. GUI naive datetime → localize to HA server timezone
|
||||
2. Convert from HA timezone to home timezone
|
||||
"""
|
||||
if dt_value.tzinfo is None:
|
||||
dt_value = dt_utils.as_local(dt_value)
|
||||
return dt_value.astimezone(home_tz)
|
||||
|
||||
|
||||
def calculate_end_of_tomorrow(home_tz: ZoneInfo) -> datetime:
|
||||
"""Calculate end of tomorrow in home timezone."""
|
||||
now_home = dt_utils.now().astimezone(home_tz)
|
||||
tomorrow = (now_home + timedelta(days=1)).date()
|
||||
# End of tomorrow = midnight at start of day after tomorrow
|
||||
return now_home.replace(
|
||||
year=tomorrow.year,
|
||||
month=tomorrow.month,
|
||||
day=tomorrow.day,
|
||||
hour=0,
|
||||
minute=0,
|
||||
second=0,
|
||||
microsecond=0,
|
||||
) + timedelta(days=1)
|
||||
|
||||
|
||||
def floor_to_quarter_hour(dt_value: datetime) -> datetime:
|
||||
"""Floor a datetime to the current quarter-hour boundary."""
|
||||
return dt_value.replace(minute=(dt_value.minute // INTERVAL_MINUTES) * INTERVAL_MINUTES, second=0, microsecond=0)
|
||||
|
||||
|
||||
def _resolve_time_with_day_offset(
|
||||
time_value: dt_time,
|
||||
day_offset: int,
|
||||
home_tz: ZoneInfo,
|
||||
) -> datetime:
|
||||
"""Resolve a time-of-day + day offset to a full datetime in home timezone."""
|
||||
now_home = dt_utils.now().astimezone(home_tz)
|
||||
target_date = (now_home + timedelta(days=day_offset)).date()
|
||||
return datetime(
|
||||
year=target_date.year,
|
||||
month=target_date.month,
|
||||
day=target_date.day,
|
||||
hour=time_value.hour,
|
||||
minute=time_value.minute,
|
||||
second=time_value.second,
|
||||
tzinfo=home_tz,
|
||||
)
|
||||
|
||||
|
||||
def _resolve_scope(scope: str, now: datetime, _home_tz: ZoneInfo) -> tuple[datetime, datetime]:
|
||||
"""
|
||||
Convert a search_scope shorthand into explicit start/end datetimes.
|
||||
|
||||
Args:
|
||||
scope: One of "today", "tomorrow", "remaining_today", "next_24h", "next_48h"
|
||||
now: Current datetime in home timezone
|
||||
home_tz: Home timezone for date calculations
|
||||
|
||||
Returns:
|
||||
Tuple of (start, end) datetimes in home timezone
|
||||
|
||||
"""
|
||||
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
tomorrow_start = today_start + timedelta(days=1)
|
||||
day_after_start = today_start + timedelta(days=2)
|
||||
|
||||
if scope == "today":
|
||||
return today_start, tomorrow_start
|
||||
if scope == "tomorrow":
|
||||
return tomorrow_start, day_after_start
|
||||
if scope == "remaining_today":
|
||||
return floor_to_quarter_hour(now), tomorrow_start
|
||||
if scope == "next_24h":
|
||||
return floor_to_quarter_hour(now), now + timedelta(hours=24)
|
||||
if scope == "next_48h":
|
||||
return floor_to_quarter_hour(now), now + timedelta(hours=48)
|
||||
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="invalid_search_scope",
|
||||
)
|
||||
|
||||
|
||||
def filter_intervals_by_price_level(
|
||||
intervals: list[dict[str, Any]],
|
||||
min_price_level: str | None,
|
||||
max_price_level: str | None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""
|
||||
Filter intervals by Tibber price level.
|
||||
|
||||
Keeps only intervals whose 'level' field is within the requested range.
|
||||
If an interval has no 'level' field it is kept (avoids silently dropping data on API changes).
|
||||
|
||||
Args:
|
||||
intervals: Price interval dicts with optional 'level' key
|
||||
min_price_level: Lower bound level (inclusive), e.g. "CHEAP"
|
||||
max_price_level: Upper bound level (inclusive), e.g. "NORMAL"
|
||||
|
||||
Returns:
|
||||
Filtered list; same list reference if no filter is active
|
||||
|
||||
"""
|
||||
if min_price_level is None and max_price_level is None:
|
||||
return intervals
|
||||
|
||||
min_rank = _PRICE_LEVEL_RANK.get(min_price_level.upper(), 0) if min_price_level else 0
|
||||
max_rank = (
|
||||
_PRICE_LEVEL_RANK.get(max_price_level.upper(), len(PRICE_LEVEL_ORDER) - 1)
|
||||
if max_price_level
|
||||
else len(PRICE_LEVEL_ORDER) - 1
|
||||
)
|
||||
|
||||
result = []
|
||||
for iv in intervals:
|
||||
level = iv.get("level")
|
||||
if level is None:
|
||||
result.append(iv)
|
||||
continue
|
||||
rank = _PRICE_LEVEL_RANK.get(str(level).upper())
|
||||
if rank is None:
|
||||
result.append(iv)
|
||||
continue
|
||||
if min_rank <= rank <= max_rank:
|
||||
result.append(iv)
|
||||
return result
|
||||
|
||||
|
||||
def build_rating_lookup(coordinator_data: dict[str, Any]) -> dict[str, str | None]:
|
||||
"""
|
||||
Build a startsAt → rating_level lookup from enriched coordinator data.
|
||||
|
||||
The coordinator's priceInfo contains rating_level (LOW/NORMAL/HIGH) computed
|
||||
from trailing 24h averages with hysteresis. Pool intervals lack this field,
|
||||
so this lookup allows annotating service responses with rating_level.
|
||||
|
||||
Args:
|
||||
coordinator_data: coordinator.data dict with enriched priceInfo
|
||||
|
||||
Returns:
|
||||
Dict mapping startsAt ISO string to lowercase rating_level (or None)
|
||||
|
||||
"""
|
||||
lookup: dict[str, str | None] = {}
|
||||
for iv in coordinator_data.get("priceInfo", []):
|
||||
starts_at = iv.get("startsAt")
|
||||
rating = iv.get("rating_level")
|
||||
if starts_at:
|
||||
lookup[starts_at] = rating.lower() if isinstance(rating, str) else None
|
||||
return lookup
|
||||
|
||||
|
||||
def build_response_interval(
|
||||
iv: dict[str, Any],
|
||||
unit_factor: int,
|
||||
rating_lookup: dict[str, str | None],
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Build an enriched interval dict for service responses.
|
||||
|
||||
Converts a raw pool interval into a companion-friendly format with
|
||||
ends_at, level, and rating_level fields.
|
||||
|
||||
Args:
|
||||
iv: Raw interval dict from pool (startsAt, total, level, ...)
|
||||
unit_factor: Price unit multiplier (1 for base unit, 100 for cents, etc.)
|
||||
rating_lookup: startsAt → rating_level mapping from coordinator data
|
||||
|
||||
Returns:
|
||||
Enriched interval dict for service response
|
||||
|
||||
"""
|
||||
starts_at = iv["startsAt"]
|
||||
if isinstance(starts_at, str):
|
||||
ends_at = (datetime.fromisoformat(starts_at) + timedelta(minutes=INTERVAL_MINUTES)).isoformat()
|
||||
else:
|
||||
ends_at = (starts_at + timedelta(minutes=INTERVAL_MINUTES)).isoformat()
|
||||
|
||||
return {
|
||||
"starts_at": starts_at,
|
||||
"ends_at": ends_at,
|
||||
"price": round(iv["total"] * unit_factor, 4),
|
||||
"level": (iv.get("level") or "").lower() or None,
|
||||
"rating_level": rating_lookup.get(starts_at),
|
||||
}
|
||||
|
||||
|
||||
def resolve_search_range(
|
||||
call_data: dict[str, Any],
|
||||
now: datetime,
|
||||
home_tz: ZoneInfo,
|
||||
) -> tuple[datetime, datetime]:
|
||||
"""
|
||||
Resolve search start/end from scope shorthand, explicit datetime, time+offset, or defaults.
|
||||
|
||||
Priority (highest to lowest):
|
||||
0. search_scope shorthand (today, tomorrow, remaining_today, next_24h, next_48h)
|
||||
1. Explicit datetime (search_start / search_end)
|
||||
2. Time-of-day + day offset (search_start_time + search_start_day_offset)
|
||||
3. Minutes offset (search_start_offset_minutes / search_end_offset_minutes)
|
||||
4. Default (now for start, end of tomorrow for end)
|
||||
|
||||
Calls validate_search_params() first to check for conflicting combinations.
|
||||
"""
|
||||
validate_search_params(call_data)
|
||||
include_current = call_data.get("include_current_interval", True)
|
||||
|
||||
# Priority 0: search_scope shorthand
|
||||
if "search_scope" in call_data:
|
||||
return _resolve_scope(call_data["search_scope"], now, home_tz)
|
||||
|
||||
# --- Resolve start ---
|
||||
if "search_start" in call_data:
|
||||
search_start = localize_to_home_tz(call_data["search_start"], home_tz)
|
||||
elif "search_start_time" in call_data:
|
||||
day_offset = call_data.get("search_start_day_offset", 0)
|
||||
search_start = _resolve_time_with_day_offset(call_data["search_start_time"], day_offset, home_tz)
|
||||
elif "search_start_offset_minutes" in call_data:
|
||||
search_start = now + timedelta(minutes=call_data["search_start_offset_minutes"])
|
||||
if include_current:
|
||||
search_start = floor_to_quarter_hour(search_start)
|
||||
else:
|
||||
search_start = floor_to_quarter_hour(now) if include_current else now
|
||||
|
||||
# --- Resolve end ---
|
||||
if "search_end" in call_data:
|
||||
search_end = localize_to_home_tz(call_data["search_end"], home_tz)
|
||||
elif "search_end_time" in call_data:
|
||||
day_offset = call_data.get("search_end_day_offset", 0)
|
||||
search_end = _resolve_time_with_day_offset(call_data["search_end_time"], day_offset, home_tz)
|
||||
elif "search_end_offset_minutes" in call_data:
|
||||
search_end = now + timedelta(minutes=call_data["search_end_offset_minutes"])
|
||||
else:
|
||||
search_end = calculate_end_of_tomorrow(home_tz)
|
||||
|
||||
if search_end <= search_start:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="end_before_start",
|
||||
)
|
||||
|
||||
return search_start, search_end
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ ATTR_ENTRY_ID: Final = "entry_id"
|
|||
# Service schema
|
||||
REFRESH_USER_DATA_SERVICE_SCHEMA: Final = vol.Schema(
|
||||
{
|
||||
vol.Required(ATTR_ENTRY_ID): str,
|
||||
vol.Optional(ATTR_ENTRY_ID, default=""): str,
|
||||
}
|
||||
)
|
||||
|
||||
|
|
@ -64,15 +64,9 @@ async def handle_refresh_user_data(call: ServiceCall) -> dict[str, Any]:
|
|||
ServiceValidationError: If entry_id is missing or invalid
|
||||
|
||||
"""
|
||||
entry_id = call.data.get(ATTR_ENTRY_ID)
|
||||
entry_id = call.data.get(ATTR_ENTRY_ID, "")
|
||||
hass = call.hass
|
||||
|
||||
if not entry_id:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Entry ID is required",
|
||||
}
|
||||
|
||||
# Get the entry and coordinator
|
||||
try:
|
||||
_, coordinator, _ = get_entry_and_data(hass, entry_id)
|
||||
|
|
|
|||
|
|
@ -59,15 +59,7 @@
|
|||
"home_already_configured": "Dieses Zuhause ist bereits in einem anderen Eintrag konfiguriert. Jedes Zuhause kann nur einmal konfiguriert werden.",
|
||||
"no_active_subscription": "Dieses Zuhause hat keinen aktiven Tibber-Vertrag. Nur Häuser mit aktivem Stromvertrag können zu Home Assistant hinzugefügt werden.",
|
||||
"subscription_expired": "Der Tibber-Vertrag für dieses Zuhause ist abgelaufen. Nur Häuser mit aktivem oder zukünftigem Stromvertrag können zu Home Assistant hinzugefügt werden.",
|
||||
"future_subscription_warning": "Hinweis: Der Tibber-Vertrag für dieses Zuhause hat noch nicht begonnen. Die Funktionalität ist möglicherweise eingeschränkt, bis der Vertrag aktiv wird.",
|
||||
"invalid_yaml_syntax": "Ungültige YAML-Syntax. Bitte überprüfe Einrückungen, Doppelpunkte und Sonderzeichen.",
|
||||
"invalid_yaml_structure": "YAML muss ein Dictionary/Objekt sein (Schlüssel: Wert-Paare), keine Liste oder reiner Text.",
|
||||
"service_call_failed": "Service-Aufruf-Validierung fehlgeschlagen: {error_detail}",
|
||||
"missing_entry_id": "Eintrag-ID wird benötigt, wurde aber nicht bereitgestellt.",
|
||||
"invalid_entry_id": "Ungültige Eintrag-ID oder Eintrag nicht gefunden.",
|
||||
"missing_home_id": "Home-ID fehlt in der Konfiguration.",
|
||||
"user_data_not_available": "Benutzerdaten nicht verfügbar. Bitte aktualisiere zuerst die Benutzerdaten.",
|
||||
"price_fetch_failed": "Preisdaten konnten nicht abgerufen werden. Bitte prüfe die Logs für Details."
|
||||
"future_subscription_warning": "Hinweis: Der Tibber-Vertrag für dieses Zuhause hat noch nicht begonnen. Die Funktionalität ist möglicherweise eingeschränkt, bis der Vertrag aktiv wird."
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "Alle verfügbaren Tibber-Zuhause sind bereits konfiguriert. Jedes Zuhause kann nur einmal konfiguriert werden.",
|
||||
|
|
@ -246,6 +238,20 @@
|
|||
"min_periods_best": "Mindestanzahl von Bestpreis-Zeiträumen pro Tag, die angestrebt werden sollen. Filter werden schrittweise gelockert, um diese Anzahl zu erreichen. Nur aktiv wenn 'Mindestanzahl anstreben' aktiviert ist. Standard: 1",
|
||||
"relaxation_attempts_best": "Wie viele Flexibilitätsstufen (Versuche) zu versuchen sind, bevor aufgegeben wird. Jeder Versuch führt alle Filterkombinationen auf der neuen Flexibilitätsstufe aus. Mehr Versuche erhöhen die Chance, zusätzliche Zeiträume zu finden, kosten aber mehr Verarbeitungszeit."
|
||||
}
|
||||
},
|
||||
"extension_settings": {
|
||||
"name": "Periodenrand-Erweiterung",
|
||||
"description": "Erkannte Bestpreisperioden optional an beiden Rändern erweitern, um angrenzende sehr günstige Intervalle aufzunehmen.",
|
||||
"data": {
|
||||
"best_price_extend_to_very_cheap": "Auf sehr günstige Intervalle erweitern",
|
||||
"best_price_max_extension_intervals": "Maximale Erweiterungsintervalle",
|
||||
"best_price_geometric_flex": "Geometrischer Flex-Bonus"
|
||||
},
|
||||
"data_description": {
|
||||
"best_price_extend_to_very_cheap": "Wenn aktiviert, erweitern sich erkannte Bestpreisperioden nach außen, um angrenzende Intervalle mit dem Preisniveau 'Sehr günstig' aufzunehmen. So werden extrem günstige Intervalle an den Rändern erkannter Perioden besser erfasst.",
|
||||
"best_price_max_extension_intervals": "Maximale Anzahl zusätzlicher Intervalle pro Seite (linker und rechter Rand). Jedes Intervall dauert 15 Minuten. Beispiel: 4 Intervalle = bis zu 1 Stunde Erweiterung pro Rand. Standard: 4",
|
||||
"best_price_geometric_flex": "Zusätzlicher Flex-Prozentsatz für Intervalle, die in ein erkanntes Preistal (V-Form) fallen. Wenn für den Tag ein Tal-Muster erkannt wird, erhalten Intervalle innerhalb der Talzone diese zusätzliche Toleranz, damit der Periodendetektor sie eher einschließt. 0 = deaktiviert. Standard: 0"
|
||||
}
|
||||
}
|
||||
},
|
||||
"submit": "↩ Speichern & Zurück"
|
||||
|
|
@ -293,6 +299,20 @@
|
|||
"min_periods_peak": "Mindestanzahl an Spitzenpreis-Zeiträumen, die pro Tag angestrebt werden. Filter werden schrittweise gelockert, um diese Anzahl zu erreichen. Nur aktiv, wenn 'Mindestanzahl Zeiträume anstreben' aktiviert ist. Standard: 1",
|
||||
"relaxation_attempts_peak": "Wie viele Flex-Stufen (Versuche) nacheinander ausprobiert werden, bevor aufgegeben wird. Jeder Versuch testet alle Filterkombinationen auf der neuen Flex-Stufe. Mehr Versuche erhöhen die Chance auf zusätzliche Spitzenpreis-Zeiträume, benötigen aber etwas mehr Rechenzeit."
|
||||
}
|
||||
},
|
||||
"extension_settings": {
|
||||
"name": "Periodenrand-Erweiterung",
|
||||
"description": "Erkannte Spitzenpreisperioden optional an beiden Rändern erweitern, um angrenzende sehr teure Intervalle aufzunehmen.",
|
||||
"data": {
|
||||
"peak_price_extend_to_very_expensive": "Auf sehr teure Intervalle erweitern",
|
||||
"peak_price_max_extension_intervals": "Maximale Erweiterungsintervalle",
|
||||
"peak_price_geometric_flex": "Geometrischer Flex-Bonus"
|
||||
},
|
||||
"data_description": {
|
||||
"peak_price_extend_to_very_expensive": "Wenn aktiviert, erweitern sich erkannte Spitzenpreisperioden nach außen, um angrenzende Intervalle mit dem Preisniveau 'Sehr teuer' aufzunehmen. So werden extrem teure Intervalle an den Rändern erkannter Perioden besser erfasst.",
|
||||
"peak_price_max_extension_intervals": "Maximale Anzahl zusätzlicher Intervalle pro Seite (linker und rechter Rand). Jedes Intervall dauert 15 Minuten. Beispiel: 4 Intervalle = bis zu 1 Stunde Erweiterung pro Rand. Standard: 4",
|
||||
"peak_price_geometric_flex": "Zusätzlicher Flex-Prozentsatz für Intervalle, die in einen erkannten Preisplateau (Λ-Form) fallen. Wenn für den Tag ein Gipfel-Muster erkannt wird, erhalten Intervalle innerhalb der Gipfelzone diese zusätzliche Toleranz, damit der Periodendetektor sie eher einschließt. 0 = deaktiviert. Standard: 0"
|
||||
}
|
||||
}
|
||||
},
|
||||
"submit": "↩ Speichern & Zurück"
|
||||
|
|
@ -945,6 +965,45 @@
|
|||
"unknown": "Unbekannt"
|
||||
}
|
||||
},
|
||||
"day_pattern_yesterday": {
|
||||
"name": "Preismuster Gestern",
|
||||
"state": {
|
||||
"valley": "Tal",
|
||||
"peak": "Gipfel",
|
||||
"double_valley": "Doppeltal",
|
||||
"double_peak": "Doppelgipfel",
|
||||
"flat": "Flach",
|
||||
"rising": "Steigend",
|
||||
"falling": "Fallend",
|
||||
"mixed": "Gemischt"
|
||||
}
|
||||
},
|
||||
"day_pattern_today": {
|
||||
"name": "Preismuster Heute",
|
||||
"state": {
|
||||
"valley": "Tal",
|
||||
"peak": "Gipfel",
|
||||
"double_valley": "Doppeltal",
|
||||
"double_peak": "Doppelgipfel",
|
||||
"flat": "Flach",
|
||||
"rising": "Steigend",
|
||||
"falling": "Fallend",
|
||||
"mixed": "Gemischt"
|
||||
}
|
||||
},
|
||||
"day_pattern_tomorrow": {
|
||||
"name": "Preismuster Morgen",
|
||||
"state": {
|
||||
"valley": "Tal",
|
||||
"peak": "Gipfel",
|
||||
"double_valley": "Doppeltal",
|
||||
"double_peak": "Doppelgipfel",
|
||||
"flat": "Flach",
|
||||
"rising": "Steigend",
|
||||
"falling": "Fallend",
|
||||
"mixed": "Gemischt"
|
||||
}
|
||||
},
|
||||
"chart_data_export": {
|
||||
"name": "Diagramm-Datenexport",
|
||||
"state": {
|
||||
|
|
@ -1055,6 +1114,62 @@
|
|||
"description": "Dieses Update enthält Breaking Changes, die automatisch angewendet wurden.\n\n**Umbenannte Entitäten ({count})**\n\nDie folgenden Entity-Keys wurden umbenannt. Deine bestehenden Entity-IDs und Automationen bleiben erhalten:\n\n{entity_list}\n\n**Geänderte Dauer-Sensorwerte**\n\nAlle Dauer-Sensoren (verbleibende Zeit, startet in, Periodendauer, Trendänderungs-Countdown) geben ihren Zustandswert jetzt in **Minuten** statt Stunden an. Die Anzeigeeinheit in Dashboards bleibt standardmäßig Stunden.\n\nWenn du Automationen mit numerischen Vergleichen auf diesen Sensoren hast, aktualisiere deine Schwellwerte:\n- Alt: `state < 0.25` (15 Minuten als Stunden)\n- Neu: `state < 15` (15 Minuten)\n\nSchließe diesen Hinweis, nachdem du deine Automationen überprüft hast."
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"no_entries_found": {
|
||||
"message": "Keine Tibber Prices Integrationseinträge gefunden. Bitte richte die Integration zuerst ein."
|
||||
},
|
||||
"multiple_entries_no_entry_id": {
|
||||
"message": "Mehrere Tibber Prices Einträge gefunden. Bitte gib 'entry_id' an, um den gewünschten Eintrag auszuwählen."
|
||||
},
|
||||
"invalid_entry_id": {
|
||||
"message": "Ungültige oder nicht verfügbare Konfigurations-Entry. Bitte überprüfe die Entry-ID und stelle sicher, dass die Integration geladen ist."
|
||||
},
|
||||
"missing_home_id": {
|
||||
"message": "Home-ID nicht im Konfigurationseintrag gefunden. Bitte konfiguriere die Integration neu."
|
||||
},
|
||||
"user_data_not_available": {
|
||||
"message": "Benutzerdaten sind noch nicht verfügbar. Bitte warte, bis das erste Datenupdate abgeschlossen ist."
|
||||
},
|
||||
"timezone_not_found": {
|
||||
"message": "Zeitzone des Zuhauses konnte nicht ermittelt werden. Bitte überprüfe die Konfiguration in deinem Tibber-Konto."
|
||||
},
|
||||
"end_before_start": {
|
||||
"message": "Endzeit muss nach der Startzeit liegen."
|
||||
},
|
||||
"price_fetch_failed": {
|
||||
"message": "Preisdaten konnten nicht von der Tibber-API abgerufen werden. Bitte versuche es später erneut."
|
||||
},
|
||||
"invalid_search_scope": {
|
||||
"message": "Ungültiger Suchbereich. Gültige Werte sind: today, tomorrow, remaining_today, next_24h, next_48h."
|
||||
},
|
||||
"scope_conflicts_with_range": {
|
||||
"message": "search_scope kann nicht mit expliziten Bereichsparametern kombiniert werden: {params}. Verwende entweder search_scope ODER explizite Start-/Endparameter."
|
||||
},
|
||||
"day_offset_requires_time": {
|
||||
"message": "{offset_param} erfordert, dass {time_param} gesetzt ist. Der Tagesoffset ändert nur das Datum eines expliziten Zeitparameters."
|
||||
},
|
||||
"min_level_exceeds_max": {
|
||||
"message": "min_price_level '{min_level}' ist höher als max_price_level '{max_level}'. Das Mindestlevel muss gleich oder niedriger als das Maximallevel sein."
|
||||
},
|
||||
"power_profile_length_mismatch": {
|
||||
"message": "power_profile hat {profile_length} Einträge, aber die Dauer erfordert {interval_count} Intervalle ({duration_minutes} Minuten). Das power_profile muss genau einen Eintrag pro 15-Minuten-Intervall haben."
|
||||
},
|
||||
"level_and_rating_filter_conflict": {
|
||||
"message": "level_filter und rating_level_filter können nicht gleichzeitig verwendet werden. Verwende nur einen Filtertyp pro Anfrage."
|
||||
},
|
||||
"insert_nulls_requires_filter": {
|
||||
"message": "insert_nulls-Modus '{mode}' erfordert einen level_filter oder rating_level_filter zur Segmentdefinition. Ohne Filter verwende insert_nulls: none."
|
||||
},
|
||||
"connect_segments_requires_segments_mode": {
|
||||
"message": "connect_segments erfordert, dass insert_nulls auf 'segments' gesetzt ist. Setze insert_nulls: segments, um Segmentverbindung zu nutzen."
|
||||
},
|
||||
"array_fields_requires_array_format": {
|
||||
"message": "array_fields kann nur mit output_format: array_of_arrays verwendet werden. Ändere das Ausgabeformat oder entferne array_fields."
|
||||
},
|
||||
"invalid_array_fields": {
|
||||
"message": "Ungültige array_fields-Vorlage. Verwende Feldnamen in geschweiften Klammern, z.B. '{start_time}, {price_per_kwh}, {level}'."
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"get_price": {
|
||||
"name": "Preisdaten abrufen",
|
||||
|
|
@ -1261,6 +1376,474 @@
|
|||
"description": "Die Konfigurationseintrag-ID für die Tibber-Integration."
|
||||
}
|
||||
}
|
||||
},
|
||||
"find_cheapest_block": {
|
||||
"name": "Günstigsten Block finden",
|
||||
"description": "Findet das günstigste zusammenhängende Zeitfenster einer bestimmten Dauer. Gedacht für Geräteplanung: Spülmaschine, Waschmaschine, Trockner usw. Gibt das günstigste Fenster mit Start-/Endzeiten und Preisstatistiken zurück.",
|
||||
"sections": {
|
||||
"search_range": {
|
||||
"name": "Suchbereich",
|
||||
"description": "Zeitfenster fuer die Suche festlegen."
|
||||
},
|
||||
"time_alternatives": {
|
||||
"name": "Alternative Zeitbereich-Optionen",
|
||||
"description": "Alternative Moeglichkeiten zum Festlegen des Suchbereichs ueber Tageszeit und Offsets."
|
||||
},
|
||||
"price_filter": {
|
||||
"name": "Preisstufen-Filter",
|
||||
"description": "Suche auf Intervalle innerhalb des angegebenen Preisstufen-Bereichs einschraenken."
|
||||
},
|
||||
"output": {
|
||||
"name": "Ausgabeoptionen",
|
||||
"description": "Kostenabschaetzung und Vergleichsausgabe steuern."
|
||||
}
|
||||
},
|
||||
"fields": {
|
||||
"entry_id": {
|
||||
"name": "Eintrag-ID",
|
||||
"description": "Die Konfigurationseintrag-ID für die Tibber-Integration."
|
||||
},
|
||||
"duration": {
|
||||
"name": "Dauer",
|
||||
"description": "Länge des gewünschten zusammenhängenden Fensters. Wird automatisch auf die nächste Viertelstunde aufgerundet. Maximum: 12 Stunden."
|
||||
},
|
||||
"search_start": {
|
||||
"name": "Suchbeginn",
|
||||
"description": "Beginn des Suchbereichs als exaktes Datum und Uhrzeit. Höchste Priorität — überschreibt alle anderen Startoptionen. Standardmäßig jetzt, wenn nicht angegeben."
|
||||
},
|
||||
"search_end": {
|
||||
"name": "Suchende",
|
||||
"description": "Ende des Suchbereichs als exaktes Datum und Uhrzeit. Höchste Priorität — überschreibt alle anderen Endoptionen. Standardmäßig Ende von morgen, wenn nicht angegeben."
|
||||
},
|
||||
"search_start_time": {
|
||||
"name": "Suchbeginn-Uhrzeit",
|
||||
"description": "Alternative: Suche ab dieser Uhrzeit starten. Mit Tages-Versatz kombinieren. Wird ignoriert, wenn Suchbeginn (Datum/Uhrzeit) gesetzt ist."
|
||||
},
|
||||
"search_start_day_offset": {
|
||||
"name": "Suchbeginn Tages-Versatz",
|
||||
"description": "Tages-Versatz für Suchbeginn-Uhrzeit. -7 bis 2: -1 = gestern, 0 = heute, 1 = morgen. Negative Werte suchen in der Vergangenheit. Nur mit Suchbeginn-Uhrzeit verwendet."
|
||||
},
|
||||
"search_end_time": {
|
||||
"name": "Suchende-Uhrzeit",
|
||||
"description": "Alternative: Suche bis zu dieser Uhrzeit. Mit Tages-Versatz kombinieren. Wird ignoriert, wenn Suchende (Datum/Uhrzeit) gesetzt ist."
|
||||
},
|
||||
"search_end_day_offset": {
|
||||
"name": "Suchende Tages-Versatz",
|
||||
"description": "Tages-Versatz für Suchende-Uhrzeit. -7 bis 2: -1 = gestern, 0 = heute, 1 = morgen. Negative Werte suchen in der Vergangenheit. Nur mit Suchende-Uhrzeit verwendet."
|
||||
},
|
||||
"search_start_offset_minutes": {
|
||||
"name": "Suchbeginn-Versatz (Minuten)",
|
||||
"description": "Alternative: Suche startet in dieser Anzahl Minuten ab jetzt. Positiv = Zukunft (60 = in 1 Stunde), negativ = Vergangenheit (-60 = vor 1 Stunde). Wird ignoriert, wenn Suchbeginn oder Suchbeginn-Uhrzeit gesetzt ist."
|
||||
},
|
||||
"search_end_offset_minutes": {
|
||||
"name": "Suchende-Versatz (Minuten)",
|
||||
"description": "Alternative: Suche endet in dieser Anzahl Minuten ab jetzt. Positiv = Zukunft (480 = in 8 Stunden), negativ = Vergangenheit (-60 = vor 1 Stunde). Wird ignoriert, wenn Suchende oder Suchende-Uhrzeit gesetzt ist."
|
||||
},
|
||||
"include_current_interval": {
|
||||
"name": "Aktuelles Intervall einbeziehen",
|
||||
"description": "Das aktuell laufende 15-Minuten-Intervall in die Suche einbeziehen. Wenn aktiviert (Standard), beginnt die Suche am Anfang des aktuellen Intervalls, sodass es Teil des Ergebnisses sein kann."
|
||||
},
|
||||
"use_base_unit": {
|
||||
"name": "Basiswährung verwenden",
|
||||
"description": "Preise in Basiswährung (EUR, NOK) statt der konfigurierten Anzeigeeinheit (ct, øre) erzwingen. Nützlich für Berechnungen."
|
||||
},
|
||||
"search_scope": {
|
||||
"name": "Suchbereich (Shortcut)",
|
||||
"description": "Kurzwahl fuer haeufige Suchbereiche. Ueberschreibt alle anderen Zeitbereich-Optionen. today/tomorrow = ganzer Kalendertag, remaining_today = jetzt bis Mitternacht, next_24h/next_48h = rollierendes Fenster ab jetzt."
|
||||
},
|
||||
"max_price_level": {
|
||||
"name": "Maximale Preisstufe",
|
||||
"description": "Nur Intervalle bis zu dieser Tibber-Preisstufe beruecksichtigen. very_cheap = restriktivste, very_expensive = keine Einschraenkung."
|
||||
},
|
||||
"min_price_level": {
|
||||
"name": "Minimale Preisstufe",
|
||||
"description": "Nur Intervalle ab dieser Tibber-Preisstufe beruecksichtigen. Nuetzlich fuer find_most_expensive, um wirklich teure Intervalle zu fokussieren."
|
||||
},
|
||||
"include_comparison_details": {
|
||||
"name": "Vergleichsdetails einschliessen",
|
||||
"description": "Das price_comparison-Ergebnis um zusaetzliche Felder ergaenzen: comparison_price_min, comparison_price_max und (nur Block) comparison_window_end."
|
||||
},
|
||||
"power_profile": {
|
||||
"name": "Leistungsprofil",
|
||||
"description": "Variable Leistungsaufnahme in Watt pro 15-Minuten-Intervall. Wenn gesetzt, gibt estimated_total_cost den tatsaechlichen Verbrauch statt einer festen 1-kW-Last an."
|
||||
}
|
||||
}
|
||||
},
|
||||
"find_most_expensive_block": {
|
||||
"name": "Teuersten Block finden",
|
||||
"description": "Findet das teuerste zusammenhängende Zeitfenster einer bestimmten Dauer. Nützlich zur Erkennung von Spitzenpreiszeiträumen, die vermieden werden sollten. Gibt das teuerste Fenster mit Start-/Endzeiten und Preisstatistiken zurück.",
|
||||
"sections": {
|
||||
"search_range": {
|
||||
"name": "Suchbereich",
|
||||
"description": "Zeitfenster fuer die Suche festlegen."
|
||||
},
|
||||
"time_alternatives": {
|
||||
"name": "Alternative Zeitbereich-Optionen",
|
||||
"description": "Alternative Moeglichkeiten zum Festlegen des Suchbereichs ueber Tageszeit und Offsets."
|
||||
},
|
||||
"price_filter": {
|
||||
"name": "Preisstufen-Filter",
|
||||
"description": "Suche auf Intervalle innerhalb des angegebenen Preisstufen-Bereichs einschraenken."
|
||||
},
|
||||
"output": {
|
||||
"name": "Ausgabeoptionen",
|
||||
"description": "Kostenabschaetzung und Vergleichsausgabe steuern."
|
||||
}
|
||||
},
|
||||
"fields": {
|
||||
"entry_id": {
|
||||
"name": "Eintrag-ID",
|
||||
"description": "Die Konfigurationseintrag-ID für die Tibber-Integration."
|
||||
},
|
||||
"duration": {
|
||||
"name": "Dauer",
|
||||
"description": "Länge des gewünschten zusammenhängenden Fensters. Wird automatisch auf die nächste Viertelstunde aufgerundet. Maximum: 12 Stunden."
|
||||
},
|
||||
"search_start": {
|
||||
"name": "Suchbeginn",
|
||||
"description": "Beginn des Suchbereichs als exaktes Datum und Uhrzeit. Höchste Priorität — überschreibt alle anderen Startoptionen. Standardmäßig jetzt, wenn nicht angegeben."
|
||||
},
|
||||
"search_end": {
|
||||
"name": "Suchende",
|
||||
"description": "Ende des Suchbereichs als exaktes Datum und Uhrzeit. Höchste Priorität — überschreibt alle anderen Endoptionen. Standardmäßig Ende von morgen, wenn nicht angegeben."
|
||||
},
|
||||
"search_start_time": {
|
||||
"name": "Suchbeginn-Uhrzeit",
|
||||
"description": "Alternative: Suche ab dieser Uhrzeit starten. Mit Tages-Versatz kombinieren. Wird ignoriert, wenn Suchbeginn (Datum/Uhrzeit) gesetzt ist."
|
||||
},
|
||||
"search_start_day_offset": {
|
||||
"name": "Suchbeginn Tages-Versatz",
|
||||
"description": "Tages-Versatz für Suchbeginn-Uhrzeit. -7 bis 2: -1 = gestern, 0 = heute, 1 = morgen. Negative Werte suchen in der Vergangenheit. Nur mit Suchbeginn-Uhrzeit verwendet."
|
||||
},
|
||||
"search_end_time": {
|
||||
"name": "Suchende-Uhrzeit",
|
||||
"description": "Alternative: Suche bis zu dieser Uhrzeit. Mit Tages-Versatz kombinieren. Wird ignoriert, wenn Suchende (Datum/Uhrzeit) gesetzt ist."
|
||||
},
|
||||
"search_end_day_offset": {
|
||||
"name": "Suchende Tages-Versatz",
|
||||
"description": "Tages-Versatz für Suchende-Uhrzeit. -7 bis 2: -1 = gestern, 0 = heute, 1 = morgen. Negative Werte suchen in der Vergangenheit. Nur mit Suchende-Uhrzeit verwendet."
|
||||
},
|
||||
"search_start_offset_minutes": {
|
||||
"name": "Suchbeginn-Versatz (Minuten)",
|
||||
"description": "Alternative: Suche startet in dieser Anzahl Minuten ab jetzt. Positiv = Zukunft (60 = in 1 Stunde), negativ = Vergangenheit (-60 = vor 1 Stunde). Wird ignoriert, wenn Suchbeginn oder Suchbeginn-Uhrzeit gesetzt ist."
|
||||
},
|
||||
"search_end_offset_minutes": {
|
||||
"name": "Suchende-Versatz (Minuten)",
|
||||
"description": "Alternative: Suche endet in dieser Anzahl Minuten ab jetzt. Positiv = Zukunft (480 = in 8 Stunden), negativ = Vergangenheit (-60 = vor 1 Stunde). Wird ignoriert, wenn Suchende oder Suchende-Uhrzeit gesetzt ist."
|
||||
},
|
||||
"include_current_interval": {
|
||||
"name": "Aktuelles Intervall einbeziehen",
|
||||
"description": "Das aktuell laufende 15-Minuten-Intervall in die Suche einbeziehen. Wenn aktiviert (Standard), beginnt die Suche am Anfang des aktuellen Intervalls, sodass es Teil des Ergebnisses sein kann."
|
||||
},
|
||||
"use_base_unit": {
|
||||
"name": "Basiswährung verwenden",
|
||||
"description": "Preise in Basiswährung (EUR, NOK) statt der konfigurierten Anzeigeeinheit (ct, øre) erzwingen. Nützlich für Berechnungen."
|
||||
},
|
||||
"search_scope": {
|
||||
"name": "Suchbereich (Shortcut)",
|
||||
"description": "Kurzwahl fuer haeufige Suchbereiche. Ueberschreibt alle anderen Zeitbereich-Optionen. today/tomorrow = ganzer Kalendertag, remaining_today = jetzt bis Mitternacht, next_24h/next_48h = rollierendes Fenster ab jetzt."
|
||||
},
|
||||
"max_price_level": {
|
||||
"name": "Maximale Preisstufe",
|
||||
"description": "Nur Intervalle bis zu dieser Tibber-Preisstufe beruecksichtigen. very_cheap = restriktivste, very_expensive = keine Einschraenkung."
|
||||
},
|
||||
"min_price_level": {
|
||||
"name": "Minimale Preisstufe",
|
||||
"description": "Nur Intervalle ab dieser Tibber-Preisstufe beruecksichtigen. Nuetzlich fuer find_most_expensive, um wirklich teure Intervalle zu fokussieren."
|
||||
},
|
||||
"include_comparison_details": {
|
||||
"name": "Vergleichsdetails einschliessen",
|
||||
"description": "Das price_comparison-Ergebnis um zusaetzliche Felder ergaenzen: comparison_price_min, comparison_price_max und (nur Block) comparison_window_end."
|
||||
},
|
||||
"power_profile": {
|
||||
"name": "Leistungsprofil",
|
||||
"description": "Variable Leistungsaufnahme in Watt pro 15-Minuten-Intervall. Wenn gesetzt, gibt estimated_total_cost den tatsaechlichen Verbrauch statt einer festen 1-kW-Last an."
|
||||
}
|
||||
}
|
||||
},
|
||||
"find_cheapest_hours": {
|
||||
"name": "Günstigste Stunden finden",
|
||||
"description": "Findet die günstigsten Intervalle für eine bestimmte Gesamtdauer, nicht unbedingt zusammenhängend. Gedacht für flexible Lasten: Batterieladung, E-Auto, Warmwasserspeicher. Gibt einen Zeitplan mit Intervallen gruppiert in zusammenhängende Segmente zurück.",
|
||||
"sections": {
|
||||
"search_range": {
|
||||
"name": "Suchbereich",
|
||||
"description": "Zeitfenster fuer die Suche festlegen."
|
||||
},
|
||||
"time_alternatives": {
|
||||
"name": "Alternative Zeitbereich-Optionen",
|
||||
"description": "Alternative Moeglichkeiten zum Festlegen des Suchbereichs ueber Tageszeit und Offsets."
|
||||
},
|
||||
"price_filter": {
|
||||
"name": "Preisstufen-Filter",
|
||||
"description": "Suche auf Intervalle innerhalb des angegebenen Preisstufen-Bereichs einschraenken."
|
||||
},
|
||||
"output": {
|
||||
"name": "Ausgabeoptionen",
|
||||
"description": "Kostenabschaetzung und Vergleichsausgabe steuern."
|
||||
}
|
||||
},
|
||||
"fields": {
|
||||
"entry_id": {
|
||||
"name": "Eintrag-ID",
|
||||
"description": "Die Konfigurationseintrag-ID für die Tibber-Integration."
|
||||
},
|
||||
"duration": {
|
||||
"name": "Dauer",
|
||||
"description": "Benötigte günstige Gesamtzeit. Wird automatisch auf die nächste Viertelstunde aufgerundet. Maximum: 24 Stunden."
|
||||
},
|
||||
"search_start": {
|
||||
"name": "Suchbeginn",
|
||||
"description": "Beginn des Suchbereichs als exaktes Datum und Uhrzeit. Höchste Priorität — überschreibt alle anderen Startoptionen. Standardmäßig jetzt, wenn nicht angegeben."
|
||||
},
|
||||
"search_end": {
|
||||
"name": "Suchende",
|
||||
"description": "Ende des Suchbereichs als exaktes Datum und Uhrzeit. Höchste Priorität — überschreibt alle anderen Endoptionen. Standardmäßig Ende von morgen, wenn nicht angegeben."
|
||||
},
|
||||
"search_start_time": {
|
||||
"name": "Suchbeginn-Uhrzeit",
|
||||
"description": "Alternative: Suche ab dieser Uhrzeit starten. Mit Tages-Versatz kombinieren. Wird ignoriert, wenn Suchbeginn (Datum/Uhrzeit) gesetzt ist."
|
||||
},
|
||||
"search_start_day_offset": {
|
||||
"name": "Suchbeginn Tages-Versatz",
|
||||
"description": "Tages-Versatz für Suchbeginn-Uhrzeit. -7 bis 2: -1 = gestern, 0 = heute, 1 = morgen. Negative Werte suchen in der Vergangenheit. Nur mit Suchbeginn-Uhrzeit verwendet."
|
||||
},
|
||||
"search_end_time": {
|
||||
"name": "Suchende-Uhrzeit",
|
||||
"description": "Alternative: Suche bis zu dieser Uhrzeit. Mit Tages-Versatz kombinieren. Wird ignoriert, wenn Suchende (Datum/Uhrzeit) gesetzt ist."
|
||||
},
|
||||
"search_end_day_offset": {
|
||||
"name": "Suchende Tages-Versatz",
|
||||
"description": "Tages-Versatz für Suchende-Uhrzeit. -7 bis 2: -1 = gestern, 0 = heute, 1 = morgen. Negative Werte suchen in der Vergangenheit. Nur mit Suchende-Uhrzeit verwendet."
|
||||
},
|
||||
"search_start_offset_minutes": {
|
||||
"name": "Suchbeginn-Versatz (Minuten)",
|
||||
"description": "Alternative: Suche startet in dieser Anzahl Minuten ab jetzt. Positiv = Zukunft (60 = in 1 Stunde), negativ = Vergangenheit (-60 = vor 1 Stunde). Wird ignoriert, wenn Suchbeginn oder Suchbeginn-Uhrzeit gesetzt ist."
|
||||
},
|
||||
"search_end_offset_minutes": {
|
||||
"name": "Suchende-Versatz (Minuten)",
|
||||
"description": "Alternative: Suche endet in dieser Anzahl Minuten ab jetzt. Positiv = Zukunft (480 = in 8 Stunden), negativ = Vergangenheit (-60 = vor 1 Stunde). Wird ignoriert, wenn Suchende oder Suchende-Uhrzeit gesetzt ist."
|
||||
},
|
||||
"include_current_interval": {
|
||||
"name": "Aktuelles Intervall einbeziehen",
|
||||
"description": "Das aktuell laufende 15-Minuten-Intervall in die Suche einbeziehen. Wenn aktiviert (Standard), beginnt die Suche am Anfang des aktuellen Intervalls, sodass es Teil des Ergebnisses sein kann."
|
||||
},
|
||||
"min_segment_duration": {
|
||||
"name": "Minimale Segmentdauer",
|
||||
"description": "Minimale zusammenhängende Laufzeit. Verhindert schnelles Ein-/Ausschalten bei Geräten mit Mindestlaufzeiten. Wird automatisch auf die nächste Viertelstunde aufgerundet. Standard: 15 Minuten. Maximum: 4 Stunden."
|
||||
},
|
||||
"use_base_unit": {
|
||||
"name": "Basiswährung verwenden",
|
||||
"description": "Preise in Basiswährung (EUR, NOK) statt der konfigurierten Anzeigeeinheit (ct, øre) erzwingen. Nützlich für Berechnungen."
|
||||
},
|
||||
"search_scope": {
|
||||
"name": "Suchbereich (Shortcut)",
|
||||
"description": "Kurzwahl fuer haeufige Suchbereiche. Ueberschreibt alle anderen Zeitbereich-Optionen. today/tomorrow = ganzer Kalendertag, remaining_today = jetzt bis Mitternacht, next_24h/next_48h = rollierendes Fenster ab jetzt."
|
||||
},
|
||||
"max_price_level": {
|
||||
"name": "Maximale Preisstufe",
|
||||
"description": "Nur Intervalle bis zu dieser Tibber-Preisstufe beruecksichtigen. very_cheap = restriktivste, very_expensive = keine Einschraenkung."
|
||||
},
|
||||
"min_price_level": {
|
||||
"name": "Minimale Preisstufe",
|
||||
"description": "Nur Intervalle ab dieser Tibber-Preisstufe beruecksichtigen. Nuetzlich fuer find_most_expensive, um wirklich teure Intervalle zu fokussieren."
|
||||
},
|
||||
"include_comparison_details": {
|
||||
"name": "Vergleichsdetails einschliessen",
|
||||
"description": "Das price_comparison-Ergebnis um zusaetzliche Felder ergaenzen: comparison_price_min, comparison_price_max und (nur Block) comparison_window_end."
|
||||
},
|
||||
"power_profile": {
|
||||
"name": "Leistungsprofil",
|
||||
"description": "Variable Leistungsaufnahme in Watt pro 15-Minuten-Intervall. Wenn gesetzt, gibt estimated_total_cost den tatsaechlichen Verbrauch statt einer festen 1-kW-Last an."
|
||||
}
|
||||
}
|
||||
},
|
||||
"find_most_expensive_hours": {
|
||||
"name": "Teuerste Stunden finden",
|
||||
"description": "Findet die teuersten Intervalle für eine bestimmte Gesamtdauer, nicht unbedingt zusammenhängend. Nützlich zur Erkennung von Spitzenpreiszeiträumen, die vermieden werden sollten. Gibt einen Zeitplan mit Intervallen gruppiert in zusammenhängende Segmente zurück.",
|
||||
"sections": {
|
||||
"search_range": {
|
||||
"name": "Suchbereich",
|
||||
"description": "Zeitfenster fuer die Suche festlegen."
|
||||
},
|
||||
"time_alternatives": {
|
||||
"name": "Alternative Zeitbereich-Optionen",
|
||||
"description": "Alternative Moeglichkeiten zum Festlegen des Suchbereichs ueber Tageszeit und Offsets."
|
||||
},
|
||||
"price_filter": {
|
||||
"name": "Preisstufen-Filter",
|
||||
"description": "Suche auf Intervalle innerhalb des angegebenen Preisstufen-Bereichs einschraenken."
|
||||
},
|
||||
"output": {
|
||||
"name": "Ausgabeoptionen",
|
||||
"description": "Kostenabschaetzung und Vergleichsausgabe steuern."
|
||||
}
|
||||
},
|
||||
"fields": {
|
||||
"entry_id": {
|
||||
"name": "Eintrag-ID",
|
||||
"description": "Die Konfigurationseintrag-ID für die Tibber-Integration."
|
||||
},
|
||||
"duration": {
|
||||
"name": "Dauer",
|
||||
"description": "Zu findende teure Gesamtzeit. Wird automatisch auf die nächste Viertelstunde aufgerundet. Maximum: 24 Stunden."
|
||||
},
|
||||
"search_start": {
|
||||
"name": "Suchbeginn",
|
||||
"description": "Beginn des Suchbereichs als exaktes Datum und Uhrzeit. Höchste Priorität — überschreibt alle anderen Startoptionen. Standardmäßig jetzt, wenn nicht angegeben."
|
||||
},
|
||||
"search_end": {
|
||||
"name": "Suchende",
|
||||
"description": "Ende des Suchbereichs als exaktes Datum und Uhrzeit. Höchste Priorität — überschreibt alle anderen Endoptionen. Standardmäßig Ende von morgen, wenn nicht angegeben."
|
||||
},
|
||||
"search_start_time": {
|
||||
"name": "Suchbeginn-Uhrzeit",
|
||||
"description": "Alternative: Suche ab dieser Uhrzeit starten. Mit Tages-Versatz kombinieren. Wird ignoriert, wenn Suchbeginn (Datum/Uhrzeit) gesetzt ist."
|
||||
},
|
||||
"search_start_day_offset": {
|
||||
"name": "Suchbeginn Tages-Versatz",
|
||||
"description": "Tages-Versatz für Suchbeginn-Uhrzeit. -7 bis 2: -1 = gestern, 0 = heute, 1 = morgen. Negative Werte suchen in der Vergangenheit. Nur mit Suchbeginn-Uhrzeit verwendet."
|
||||
},
|
||||
"search_end_time": {
|
||||
"name": "Suchende-Uhrzeit",
|
||||
"description": "Alternative: Suche bis zu dieser Uhrzeit. Mit Tages-Versatz kombinieren. Wird ignoriert, wenn Suchende (Datum/Uhrzeit) gesetzt ist."
|
||||
},
|
||||
"search_end_day_offset": {
|
||||
"name": "Suchende Tages-Versatz",
|
||||
"description": "Tages-Versatz für Suchende-Uhrzeit. -7 bis 2: -1 = gestern, 0 = heute, 1 = morgen. Negative Werte suchen in der Vergangenheit. Nur mit Suchende-Uhrzeit verwendet."
|
||||
},
|
||||
"search_start_offset_minutes": {
|
||||
"name": "Suchbeginn-Versatz (Minuten)",
|
||||
"description": "Alternative: Suche startet in dieser Anzahl Minuten ab jetzt. Positiv = Zukunft (60 = in 1 Stunde), negativ = Vergangenheit (-60 = vor 1 Stunde). Wird ignoriert, wenn Suchbeginn oder Suchbeginn-Uhrzeit gesetzt ist."
|
||||
},
|
||||
"search_end_offset_minutes": {
|
||||
"name": "Suchende-Versatz (Minuten)",
|
||||
"description": "Alternative: Suche endet in dieser Anzahl Minuten ab jetzt. Positiv = Zukunft (480 = in 8 Stunden), negativ = Vergangenheit (-60 = vor 1 Stunde). Wird ignoriert, wenn Suchende oder Suchende-Uhrzeit gesetzt ist."
|
||||
},
|
||||
"include_current_interval": {
|
||||
"name": "Aktuelles Intervall einbeziehen",
|
||||
"description": "Das aktuell laufende 15-Minuten-Intervall in die Suche einbeziehen. Wenn aktiviert (Standard), beginnt die Suche am Anfang des aktuellen Intervalls, sodass es Teil des Ergebnisses sein kann."
|
||||
},
|
||||
"min_segment_duration": {
|
||||
"name": "Minimale Segmentdauer",
|
||||
"description": "Minimale zusammenhängende Laufzeit. Verhindert schnelles Ein-/Ausschalten bei Geräten mit Mindestlaufzeiten. Wird automatisch auf die nächste Viertelstunde aufgerundet. Standard: 15 Minuten. Maximum: 4 Stunden."
|
||||
},
|
||||
"use_base_unit": {
|
||||
"name": "Basiswährung verwenden",
|
||||
"description": "Preise in Basiswährung (EUR, NOK) statt der konfigurierten Anzeigeeinheit (ct, øre) erzwingen. Nützlich für Berechnungen."
|
||||
},
|
||||
"search_scope": {
|
||||
"name": "Suchbereich (Shortcut)",
|
||||
"description": "Kurzwahl fuer haeufige Suchbereiche. Ueberschreibt alle anderen Zeitbereich-Optionen. today/tomorrow = ganzer Kalendertag, remaining_today = jetzt bis Mitternacht, next_24h/next_48h = rollierendes Fenster ab jetzt."
|
||||
},
|
||||
"max_price_level": {
|
||||
"name": "Maximale Preisstufe",
|
||||
"description": "Nur Intervalle bis zu dieser Tibber-Preisstufe beruecksichtigen. very_cheap = restriktivste, very_expensive = keine Einschraenkung."
|
||||
},
|
||||
"min_price_level": {
|
||||
"name": "Minimale Preisstufe",
|
||||
"description": "Nur Intervalle ab dieser Tibber-Preisstufe beruecksichtigen. Nuetzlich fuer find_most_expensive, um wirklich teure Intervalle zu fokussieren."
|
||||
},
|
||||
"include_comparison_details": {
|
||||
"name": "Vergleichsdetails einschliessen",
|
||||
"description": "Das price_comparison-Ergebnis um zusaetzliche Felder ergaenzen: comparison_price_min, comparison_price_max und (nur Block) comparison_window_end."
|
||||
},
|
||||
"power_profile": {
|
||||
"name": "Leistungsprofil",
|
||||
"description": "Variable Leistungsaufnahme in Watt pro 15-Minuten-Intervall. Wenn gesetzt, gibt estimated_total_cost den tatsaechlichen Verbrauch statt einer festen 1-kW-Last an."
|
||||
}
|
||||
}
|
||||
},
|
||||
"find_cheapest_schedule": {
|
||||
"name": "Guenstigstes Programm planen",
|
||||
"description": "Plant mehrere Geraete optimal ohne Zeitueberschneidung. Jede Aufgabe erhaelt das guenstigste verfuegbare zusammenhaengende Zeitfenster.",
|
||||
"sections": {
|
||||
"scheduling_options": {
|
||||
"name": "Planungsoptionen",
|
||||
"description": "Aufgaben und Puffer zwischen den Geraeteaufzeiten konfigurieren."
|
||||
},
|
||||
"search_range": {
|
||||
"name": "Suchbereich",
|
||||
"description": "Zeitfenster fuer die Suche festlegen."
|
||||
},
|
||||
"time_alternatives": {
|
||||
"name": "Alternative Zeitbereich-Optionen",
|
||||
"description": "Alternative Moeglichkeiten zum Festlegen des Suchbereichs ueber Tageszeit und Offsets."
|
||||
},
|
||||
"price_filter": {
|
||||
"name": "Preisstufen-Filter",
|
||||
"description": "Suche auf Intervalle innerhalb des angegebenen Preisstufen-Bereichs einschraenken."
|
||||
},
|
||||
"output": {
|
||||
"name": "Ausgabeoptionen",
|
||||
"description": "Kostenabschaetzung und Vergleichsausgabe steuern."
|
||||
}
|
||||
},
|
||||
"fields": {
|
||||
"entry_id": {
|
||||
"name": "Eintrag-ID",
|
||||
"description": "Die Konfigurationseintrag-ID für die Tibber-Integration."
|
||||
},
|
||||
"tasks": {
|
||||
"name": "Aufgaben",
|
||||
"description": "Liste der zu planenden Aufgaben. Jede Aufgabe benoetigt name (Text) und duration (hh:mm:ss). Optional: power_profile (Watt pro 15-min-Intervall). Maximal 4 Aufgaben."
|
||||
},
|
||||
"gap_minutes": {
|
||||
"name": "Pause zwischen Aufgaben (Minuten)",
|
||||
"description": "Mindestpause in Minuten zwischen aufeinanderfolgenden Aufgaben. Wird auf 15 Minuten aufgerundet. Standard: 0 (keine Pause)."
|
||||
},
|
||||
"search_scope": {
|
||||
"name": "Suchbereich (Shortcut)",
|
||||
"description": "Kurzwahl fuer haeufige Suchbereiche. Ueberschreibt alle anderen Zeitbereich-Optionen. today/tomorrow = ganzer Kalendertag, remaining_today = jetzt bis Mitternacht, next_24h/next_48h = rollierendes Fenster ab jetzt."
|
||||
},
|
||||
"search_start": {
|
||||
"name": "Suchbeginn",
|
||||
"description": "Beginn des Suchbereichs als exaktes Datum und Uhrzeit. Höchste Priorität — überschreibt alle anderen Startoptionen. Standardmäßig jetzt, wenn nicht angegeben."
|
||||
},
|
||||
"search_end": {
|
||||
"name": "Suchende",
|
||||
"description": "Ende des Suchbereichs als exaktes Datum und Uhrzeit. Höchste Priorität — überschreibt alle anderen Endoptionen. Standardmäßig Ende von morgen, wenn nicht angegeben."
|
||||
},
|
||||
"search_start_time": {
|
||||
"name": "Suchbeginn-Uhrzeit",
|
||||
"description": "Alternative: Suche ab dieser Uhrzeit starten. Mit Tages-Versatz kombinieren. Wird ignoriert, wenn Suchbeginn (Datum/Uhrzeit) gesetzt ist."
|
||||
},
|
||||
"search_start_day_offset": {
|
||||
"name": "Suchbeginn Tages-Versatz",
|
||||
"description": "Tages-Versatz für Suchbeginn-Uhrzeit. -7 bis 2: -1 = gestern, 0 = heute, 1 = morgen. Negative Werte suchen in der Vergangenheit. Nur mit Suchbeginn-Uhrzeit verwendet."
|
||||
},
|
||||
"search_end_time": {
|
||||
"name": "Suchende-Uhrzeit",
|
||||
"description": "Alternative: Suche bis zu dieser Uhrzeit. Mit Tages-Versatz kombinieren. Wird ignoriert, wenn Suchende (Datum/Uhrzeit) gesetzt ist."
|
||||
},
|
||||
"search_end_day_offset": {
|
||||
"name": "Suchende Tages-Versatz",
|
||||
"description": "Tages-Versatz für Suchende-Uhrzeit. -7 bis 2: -1 = gestern, 0 = heute, 1 = morgen. Negative Werte suchen in der Vergangenheit. Nur mit Suchende-Uhrzeit verwendet."
|
||||
},
|
||||
"search_start_offset_minutes": {
|
||||
"name": "Suchbeginn-Versatz (Minuten)",
|
||||
"description": "Alternative: Suche startet in dieser Anzahl Minuten ab jetzt. Positiv = Zukunft (60 = in 1 Stunde), negativ = Vergangenheit (-60 = vor 1 Stunde). Wird ignoriert, wenn Suchbeginn oder Suchbeginn-Uhrzeit gesetzt ist."
|
||||
},
|
||||
"search_end_offset_minutes": {
|
||||
"name": "Suchende-Versatz (Minuten)",
|
||||
"description": "Alternative: Suche endet in dieser Anzahl Minuten ab jetzt. Positiv = Zukunft (480 = in 8 Stunden), negativ = Vergangenheit (-60 = vor 1 Stunde). Wird ignoriert, wenn Suchende oder Suchende-Uhrzeit gesetzt ist."
|
||||
},
|
||||
"include_current_interval": {
|
||||
"name": "Aktuelles Intervall einbeziehen",
|
||||
"description": "Das aktuell laufende 15-Minuten-Intervall in die Suche einbeziehen. Wenn aktiviert (Standard), beginnt die Suche am Anfang des aktuellen Intervalls, sodass es Teil des Ergebnisses sein kann."
|
||||
},
|
||||
"max_price_level": {
|
||||
"name": "Maximale Preisstufe",
|
||||
"description": "Nur Intervalle bis zu dieser Tibber-Preisstufe beruecksichtigen. very_cheap = restriktivste, very_expensive = keine Einschraenkung."
|
||||
},
|
||||
"min_price_level": {
|
||||
"name": "Minimale Preisstufe",
|
||||
"description": "Nur Intervalle ab dieser Tibber-Preisstufe beruecksichtigen. Nuetzlich fuer find_most_expensive, um wirklich teure Intervalle zu fokussieren."
|
||||
},
|
||||
"use_base_unit": {
|
||||
"name": "Basiswährung verwenden",
|
||||
"description": "Preise in Basiswährung (EUR, NOK) statt der konfigurierten Anzeigeeinheit (ct, øre) erzwingen. Nützlich für Berechnungen."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"selector": {
|
||||
|
|
@ -1361,6 +1944,15 @@
|
|||
"median": "Median",
|
||||
"mean": "Arithmetisches Mittel"
|
||||
}
|
||||
},
|
||||
"search_scope": {
|
||||
"options": {
|
||||
"today": "Heute",
|
||||
"tomorrow": "Morgen",
|
||||
"remaining_today": "Rest heute",
|
||||
"next_24h": "Naechste 24 Stunden",
|
||||
"next_48h": "Naechste 48 Stunden"
|
||||
}
|
||||
}
|
||||
},
|
||||
"title": "Tibber Preisinformationen & Bewertungen"
|
||||
|
|
|
|||
|
|
@ -59,15 +59,7 @@
|
|||
"home_already_configured": "This home is already configured in another entry. Each home can only be configured once.",
|
||||
"no_active_subscription": "This home does not have an active Tibber contract. Only homes with active electricity contracts can be added to Home Assistant.",
|
||||
"subscription_expired": "The Tibber contract for this home has expired. Only homes with active or future electricity contracts can be added to Home Assistant.",
|
||||
"future_subscription_warning": "Note: This home's Tibber contract has not started yet. Functionality may be limited until the contract becomes active.",
|
||||
"invalid_yaml_syntax": "Invalid YAML syntax. Please check indentation, colons, and special characters.",
|
||||
"invalid_yaml_structure": "YAML must be a dictionary/object (key: value pairs), not a list or plain text.",
|
||||
"service_call_failed": "Service call validation failed: {error_detail}",
|
||||
"missing_entry_id": "Entry ID is required but was not provided.",
|
||||
"invalid_entry_id": "Invalid entry ID or entry not found.",
|
||||
"missing_home_id": "Home ID is missing from the configuration entry.",
|
||||
"user_data_not_available": "User data is not available. Please refresh user data first.",
|
||||
"price_fetch_failed": "Failed to fetch price data. Please check logs for details."
|
||||
"future_subscription_warning": "Note: This home's Tibber contract has not started yet. Functionality may be limited until the contract becomes active."
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "All available Tibber homes are already configured. Each home can only be configured once.",
|
||||
|
|
@ -257,6 +249,20 @@
|
|||
"min_periods_best": "Minimum number of best price periods to aim for per day. Filters will be relaxed step-by-step to try achieving this count. Only active when 'Achieve Minimum Count' is enabled. Default: 1",
|
||||
"relaxation_attempts_best": "How many flex levels (attempts) to try before giving up. Each attempt runs all filter combinations at the new flex level. More attempts increase the chance of finding additional periods at the cost of longer processing time."
|
||||
}
|
||||
},
|
||||
"extension_settings": {
|
||||
"name": "Period Edge Extension",
|
||||
"description": "Optionally extend detected best price periods at both edges to absorb adjacent very cheap intervals.",
|
||||
"data": {
|
||||
"best_price_extend_to_very_cheap": "Extend to Very Cheap Intervals",
|
||||
"best_price_max_extension_intervals": "Maximum Extension Intervals",
|
||||
"best_price_geometric_flex": "Geometric Flex Bonus"
|
||||
},
|
||||
"data_description": {
|
||||
"best_price_extend_to_very_cheap": "When enabled, detected best price periods expand outward to absorb adjacent intervals with a 'Very cheap' price level. This widens low-price windows to better capture extremely cheap intervals at the edges of detected periods.",
|
||||
"best_price_max_extension_intervals": "Maximum number of additional intervals to absorb per side (left and right edge). Each interval is 15 minutes. Example: 4 intervals = up to 1 hour extension per edge. Default: 4",
|
||||
"best_price_geometric_flex": "Extra flex percentage applied to intervals that fall inside a detected price valley (V-shape). When a valley pattern is detected for the day, intervals within the valley zone get this additional tolerance, making the period detector more likely to include them. 0 = disabled. Default: 0"
|
||||
}
|
||||
}
|
||||
},
|
||||
"submit": "↩ Save & Back"
|
||||
|
|
@ -304,6 +310,20 @@
|
|||
"min_periods_peak": "Minimum number of peak price periods to aim for per day. Filters will be relaxed step-by-step to try achieving this count. Only active when 'Achieve Minimum Count' is enabled. Default: 1",
|
||||
"relaxation_attempts_peak": "How many flex levels (attempts) to try before giving up. Each attempt runs all filter combinations at the new flex level. More attempts increase the chance of finding additional peak periods at the cost of longer processing time."
|
||||
}
|
||||
},
|
||||
"extension_settings": {
|
||||
"name": "Period Edge Extension",
|
||||
"description": "Optionally extend detected peak price periods at both edges to absorb adjacent very expensive intervals.",
|
||||
"data": {
|
||||
"peak_price_extend_to_very_expensive": "Extend to Very Expensive Intervals",
|
||||
"peak_price_max_extension_intervals": "Maximum Extension Intervals",
|
||||
"peak_price_geometric_flex": "Geometric Flex Bonus"
|
||||
},
|
||||
"data_description": {
|
||||
"peak_price_extend_to_very_expensive": "When enabled, detected peak price periods expand outward to absorb adjacent intervals with a 'Very expensive' price level. This widens high-price windows to better capture extremely expensive intervals at the edges of detected periods.",
|
||||
"peak_price_max_extension_intervals": "Maximum number of additional intervals to absorb per side (left and right edge). Each interval is 15 minutes. Example: 4 intervals = up to 1 hour extension per edge. Default: 4",
|
||||
"peak_price_geometric_flex": "Extra flex percentage applied to intervals that fall inside a detected price peak (Λ-shape). When a peak pattern is detected for the day, intervals within the peak zone get this additional tolerance, making the period detector more likely to include them. 0 = disabled. Default: 0"
|
||||
}
|
||||
}
|
||||
},
|
||||
"submit": "↩ Save & Back"
|
||||
|
|
@ -945,6 +965,45 @@
|
|||
"unknown": "Unknown"
|
||||
}
|
||||
},
|
||||
"day_pattern_yesterday": {
|
||||
"name": "Yesterday's Price Pattern",
|
||||
"state": {
|
||||
"valley": "Valley",
|
||||
"peak": "Peak",
|
||||
"double_valley": "Double Valley",
|
||||
"double_peak": "Double Peak",
|
||||
"flat": "Flat",
|
||||
"rising": "Rising",
|
||||
"falling": "Falling",
|
||||
"mixed": "Mixed"
|
||||
}
|
||||
},
|
||||
"day_pattern_today": {
|
||||
"name": "Today's Price Pattern",
|
||||
"state": {
|
||||
"valley": "Valley",
|
||||
"peak": "Peak",
|
||||
"double_valley": "Double Valley",
|
||||
"double_peak": "Double Peak",
|
||||
"flat": "Flat",
|
||||
"rising": "Rising",
|
||||
"falling": "Falling",
|
||||
"mixed": "Mixed"
|
||||
}
|
||||
},
|
||||
"day_pattern_tomorrow": {
|
||||
"name": "Tomorrow's Price Pattern",
|
||||
"state": {
|
||||
"valley": "Valley",
|
||||
"peak": "Peak",
|
||||
"double_valley": "Double Valley",
|
||||
"double_peak": "Double Peak",
|
||||
"flat": "Flat",
|
||||
"rising": "Rising",
|
||||
"falling": "Falling",
|
||||
"mixed": "Mixed"
|
||||
}
|
||||
},
|
||||
"chart_data_export": {
|
||||
"name": "Chart Data Export",
|
||||
"state": {
|
||||
|
|
@ -1055,6 +1114,62 @@
|
|||
"description": "This update includes breaking changes that were applied automatically.\n\n**Renamed Entities ({count})**\n\nThe following entity keys were renamed. Your existing entity IDs and automations remain intact:\n\n{entity_list}\n\n**Duration Sensor Value Change**\n\nAll duration sensors (remaining time, starts in, period duration, trend change countdown) now report their state value in **minutes** instead of hours. The display unit in dashboards remains hours by default.\n\nIf you have automations using numeric comparisons on these sensors, update your thresholds:\n- Old: `state < 0.25` (15 minutes as hours)\n- New: `state < 15` (15 minutes)\n\nDismiss this notice after reviewing your automations."
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"no_entries_found": {
|
||||
"message": "No Tibber Prices integration entries found. Please set up the integration first."
|
||||
},
|
||||
"multiple_entries_no_entry_id": {
|
||||
"message": "Multiple Tibber Prices entries found. Please specify 'entry_id' to select which entry to use."
|
||||
},
|
||||
"invalid_entry_id": {
|
||||
"message": "Invalid or unavailable config entry. Please check the entry ID and ensure the integration is loaded."
|
||||
},
|
||||
"missing_home_id": {
|
||||
"message": "Home ID not found in the config entry. Please reconfigure the integration."
|
||||
},
|
||||
"user_data_not_available": {
|
||||
"message": "User data is not yet available. Please wait for the first data update to complete."
|
||||
},
|
||||
"timezone_not_found": {
|
||||
"message": "Could not determine the home timezone. Please verify the home configuration in your Tibber account."
|
||||
},
|
||||
"end_before_start": {
|
||||
"message": "End time must be after start time."
|
||||
},
|
||||
"price_fetch_failed": {
|
||||
"message": "Failed to fetch price data from the Tibber API. Please try again later."
|
||||
},
|
||||
"invalid_search_scope": {
|
||||
"message": "Invalid search scope value. Valid scopes are: today, tomorrow, remaining_today, next_24h, next_48h."
|
||||
},
|
||||
"scope_conflicts_with_range": {
|
||||
"message": "search_scope cannot be combined with explicit range parameters: {params}. Use either search_scope OR explicit start/end parameters."
|
||||
},
|
||||
"day_offset_requires_time": {
|
||||
"message": "{offset_param} requires {time_param} to be set. Day offset only modifies the date of an explicit time parameter."
|
||||
},
|
||||
"min_level_exceeds_max": {
|
||||
"message": "min_price_level '{min_level}' is higher than max_price_level '{max_level}'. The minimum level must be equal to or lower than the maximum level."
|
||||
},
|
||||
"power_profile_length_mismatch": {
|
||||
"message": "power_profile has {profile_length} entries but the duration requires {interval_count} intervals ({duration_minutes} minutes). The power_profile must have exactly one entry per 15-minute interval."
|
||||
},
|
||||
"level_and_rating_filter_conflict": {
|
||||
"message": "level_filter and rating_level_filter cannot be used together. Use only one filter type per request."
|
||||
},
|
||||
"insert_nulls_requires_filter": {
|
||||
"message": "insert_nulls mode '{mode}' requires a level_filter or rating_level_filter to define segments. Without a filter, use insert_nulls: none."
|
||||
},
|
||||
"connect_segments_requires_segments_mode": {
|
||||
"message": "connect_segments requires insert_nulls to be set to 'segments'. Set insert_nulls: segments to use segment connection."
|
||||
},
|
||||
"array_fields_requires_array_format": {
|
||||
"message": "array_fields can only be used with output_format: array_of_arrays. Change the output format or remove array_fields."
|
||||
},
|
||||
"invalid_array_fields": {
|
||||
"message": "Invalid array_fields template. Use field names in curly braces, e.g. '{start_time}, {price_per_kwh}, {level}'."
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"get_price": {
|
||||
"name": "Get Price Data",
|
||||
|
|
@ -1262,6 +1377,474 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"find_cheapest_block": {
|
||||
"name": "Find Cheapest Block",
|
||||
"description": "Finds the cheapest contiguous time window of a given duration. Designed for appliance scheduling: dishwasher, washing machine, dryer, etc. Returns the single cheapest window with start/end times and price statistics.",
|
||||
"sections": {
|
||||
"search_range": {
|
||||
"name": "Search Range",
|
||||
"description": "Define the time window to search within."
|
||||
},
|
||||
"time_alternatives": {
|
||||
"name": "Alternative Time Range Options",
|
||||
"description": "Alternative ways to define the search range using time-of-day and offsets."
|
||||
},
|
||||
"price_filter": {
|
||||
"name": "Price Level Filter",
|
||||
"description": "Restrict search to intervals within the specified price level range."
|
||||
},
|
||||
"output": {
|
||||
"name": "Output Options",
|
||||
"description": "Control cost estimation and comparison output."
|
||||
}
|
||||
},
|
||||
"fields": {
|
||||
"entry_id": {
|
||||
"name": "Entry ID",
|
||||
"description": "The config entry ID for the Tibber integration."
|
||||
},
|
||||
"duration": {
|
||||
"name": "Duration",
|
||||
"description": "Length of the desired contiguous window. Automatically rounded up to the next quarter-hour. Maximum: 12 hours."
|
||||
},
|
||||
"search_start": {
|
||||
"name": "Search Start",
|
||||
"description": "Start of the search range as exact date and time. Highest priority — overrides all other start options. Defaults to now if not specified."
|
||||
},
|
||||
"search_end": {
|
||||
"name": "Search End",
|
||||
"description": "End of the search range as exact date and time. Highest priority — overrides all other end options. Defaults to end of tomorrow if not specified."
|
||||
},
|
||||
"search_start_time": {
|
||||
"name": "Search Start Time",
|
||||
"description": "Alternative: start searching at this time of day. Combine with day offset. Ignored if Search Start (datetime) is set."
|
||||
},
|
||||
"search_start_day_offset": {
|
||||
"name": "Search Start Day Offset",
|
||||
"description": "Day offset for Search Start Time. -7 to 2: -1 = yesterday, 0 = today, 1 = tomorrow. Negative values search in the past. Only used with Search Start Time."
|
||||
},
|
||||
"search_end_time": {
|
||||
"name": "Search End Time",
|
||||
"description": "Alternative: stop searching at this time of day. Combine with day offset. Ignored if Search End (datetime) is set."
|
||||
},
|
||||
"search_end_day_offset": {
|
||||
"name": "Search End Day Offset",
|
||||
"description": "Day offset for Search End Time. -7 to 2: -1 = yesterday, 0 = today, 1 = tomorrow. Negative values search in the past. Only used with Search End Time."
|
||||
},
|
||||
"search_start_offset_minutes": {
|
||||
"name": "Search Start Offset (minutes)",
|
||||
"description": "Alternative: start searching this many minutes from now. Positive = future (60 = in 1 hour), negative = past (-60 = 1 hour ago). Ignored if Search Start or Search Start Time is set."
|
||||
},
|
||||
"search_end_offset_minutes": {
|
||||
"name": "Search End Offset (minutes)",
|
||||
"description": "Alternative: stop searching this many minutes from now. Positive = future (480 = in 8 hours), negative = past (-60 = 1 hour ago). Ignored if Search End or Search End Time is set."
|
||||
},
|
||||
"include_current_interval": {
|
||||
"name": "Include Current Interval",
|
||||
"description": "Include the currently running 15-minute interval in the search. When enabled (default), the search starts at the beginning of the current interval so it can be part of the result."
|
||||
},
|
||||
"use_base_unit": {
|
||||
"name": "Use Base Currency Unit",
|
||||
"description": "Force prices in base currency (EUR, NOK) instead of the configured display unit (ct, øre). Useful for calculations."
|
||||
},
|
||||
"search_scope": {
|
||||
"name": "Search Scope",
|
||||
"description": "Shorthand for common search ranges. Overrides all other time range options. today / tomorrow = full calendar day, remaining_today = now until midnight, next_24h / next_48h = rolling window from now."
|
||||
},
|
||||
"max_price_level": {
|
||||
"name": "Maximum Price Level",
|
||||
"description": "Only consider intervals at or below this Tibber price level. very_cheap = most restrictive, very_expensive = no restriction."
|
||||
},
|
||||
"min_price_level": {
|
||||
"name": "Minimum Price Level",
|
||||
"description": "Only consider intervals at or above this Tibber price level. Useful for find_most_expensive to focus on truly expensive intervals."
|
||||
},
|
||||
"include_comparison_details": {
|
||||
"name": "Include Comparison Details",
|
||||
"description": "Enrich the price_comparison result with additional fields: comparison_price_min, comparison_price_max, and (block only) comparison_window_end."
|
||||
},
|
||||
"power_profile": {
|
||||
"name": "Power Profile",
|
||||
"description": "Variable power draw in watts per 15-minute interval. When set, estimated_total_cost reflects actual consumption instead of a flat 1 kW load. The profile is extended by repeating the last value if shorter than the window."
|
||||
}
|
||||
}
|
||||
},
|
||||
"find_most_expensive_block": {
|
||||
"name": "Find Most Expensive Block",
|
||||
"description": "Finds the most expensive contiguous time window of a given duration. Useful for identifying peak price periods to avoid. Returns the single most expensive window with start/end times and price statistics.",
|
||||
"sections": {
|
||||
"search_range": {
|
||||
"name": "Search Range",
|
||||
"description": "Define the time window to search within."
|
||||
},
|
||||
"time_alternatives": {
|
||||
"name": "Alternative Time Range Options",
|
||||
"description": "Alternative ways to define the search range using time-of-day and offsets."
|
||||
},
|
||||
"price_filter": {
|
||||
"name": "Price Level Filter",
|
||||
"description": "Restrict search to intervals within the specified price level range."
|
||||
},
|
||||
"output": {
|
||||
"name": "Output Options",
|
||||
"description": "Control cost estimation and comparison output."
|
||||
}
|
||||
},
|
||||
"fields": {
|
||||
"entry_id": {
|
||||
"name": "Entry ID",
|
||||
"description": "The config entry ID for the Tibber integration."
|
||||
},
|
||||
"duration": {
|
||||
"name": "Duration",
|
||||
"description": "Length of the desired contiguous window. Automatically rounded up to the next quarter-hour. Maximum: 12 hours."
|
||||
},
|
||||
"search_start": {
|
||||
"name": "Search Start",
|
||||
"description": "Start of the search range as exact date and time. Highest priority — overrides all other start options. Defaults to now if not specified."
|
||||
},
|
||||
"search_end": {
|
||||
"name": "Search End",
|
||||
"description": "End of the search range as exact date and time. Highest priority — overrides all other end options. Defaults to end of tomorrow if not specified."
|
||||
},
|
||||
"search_start_time": {
|
||||
"name": "Search Start Time",
|
||||
"description": "Alternative: start searching at this time of day. Combine with day offset. Ignored if Search Start (datetime) is set."
|
||||
},
|
||||
"search_start_day_offset": {
|
||||
"name": "Search Start Day Offset",
|
||||
"description": "Day offset for Search Start Time. -7 to 2: -1 = yesterday, 0 = today, 1 = tomorrow. Negative values search in the past. Only used with Search Start Time."
|
||||
},
|
||||
"search_end_time": {
|
||||
"name": "Search End Time",
|
||||
"description": "Alternative: stop searching at this time of day. Combine with day offset. Ignored if Search End (datetime) is set."
|
||||
},
|
||||
"search_end_day_offset": {
|
||||
"name": "Search End Day Offset",
|
||||
"description": "Day offset for Search End Time. -7 to 2: -1 = yesterday, 0 = today, 1 = tomorrow. Negative values search in the past. Only used with Search End Time."
|
||||
},
|
||||
"search_start_offset_minutes": {
|
||||
"name": "Search Start Offset (minutes)",
|
||||
"description": "Alternative: start searching this many minutes from now. Positive = future (60 = in 1 hour), negative = past (-60 = 1 hour ago). Ignored if Search Start or Search Start Time is set."
|
||||
},
|
||||
"search_end_offset_minutes": {
|
||||
"name": "Search End Offset (minutes)",
|
||||
"description": "Alternative: stop searching this many minutes from now. Positive = future (480 = in 8 hours), negative = past (-60 = 1 hour ago). Ignored if Search End or Search End Time is set."
|
||||
},
|
||||
"include_current_interval": {
|
||||
"name": "Include Current Interval",
|
||||
"description": "Include the currently running 15-minute interval in the search. When enabled (default), the search starts at the beginning of the current interval so it can be part of the result."
|
||||
},
|
||||
"use_base_unit": {
|
||||
"name": "Use Base Currency Unit",
|
||||
"description": "Force prices in base currency (EUR, NOK) instead of the configured display unit (ct, øre). Useful for calculations."
|
||||
},
|
||||
"search_scope": {
|
||||
"name": "Search Scope",
|
||||
"description": "Shorthand for common search ranges. Overrides all other time range options. today / tomorrow = full calendar day, remaining_today = now until midnight, next_24h / next_48h = rolling window from now."
|
||||
},
|
||||
"max_price_level": {
|
||||
"name": "Maximum Price Level",
|
||||
"description": "Only consider intervals at or below this Tibber price level. very_cheap = most restrictive, very_expensive = no restriction."
|
||||
},
|
||||
"min_price_level": {
|
||||
"name": "Minimum Price Level",
|
||||
"description": "Only consider intervals at or above this Tibber price level. Useful for find_most_expensive to focus on truly expensive intervals."
|
||||
},
|
||||
"include_comparison_details": {
|
||||
"name": "Include Comparison Details",
|
||||
"description": "Enrich the price_comparison result with additional fields: comparison_price_min, comparison_price_max, and (block only) comparison_window_end."
|
||||
},
|
||||
"power_profile": {
|
||||
"name": "Power Profile",
|
||||
"description": "Variable power draw in watts per 15-minute interval. When set, estimated_total_cost reflects actual consumption instead of a flat 1 kW load. The profile is extended by repeating the last value if shorter than the window."
|
||||
}
|
||||
}
|
||||
},
|
||||
"find_cheapest_hours": {
|
||||
"name": "Find Cheapest Hours",
|
||||
"description": "Finds the cheapest intervals totaling a given duration, not necessarily contiguous. Designed for flexible loads: battery charging, EV, water heater. Returns a schedule of intervals grouped into contiguous segments.",
|
||||
"sections": {
|
||||
"search_range": {
|
||||
"name": "Search Range",
|
||||
"description": "Define the time window to search within."
|
||||
},
|
||||
"time_alternatives": {
|
||||
"name": "Alternative Time Range Options",
|
||||
"description": "Alternative ways to define the search range using time-of-day and offsets."
|
||||
},
|
||||
"price_filter": {
|
||||
"name": "Price Level Filter",
|
||||
"description": "Restrict search to intervals within the specified price level range."
|
||||
},
|
||||
"output": {
|
||||
"name": "Output Options",
|
||||
"description": "Control cost estimation and comparison output."
|
||||
}
|
||||
},
|
||||
"fields": {
|
||||
"entry_id": {
|
||||
"name": "Entry ID",
|
||||
"description": "The config entry ID for the Tibber integration."
|
||||
},
|
||||
"duration": {
|
||||
"name": "Duration",
|
||||
"description": "Total cheap time needed. Automatically rounded up to the next quarter-hour. Maximum: 24 hours."
|
||||
},
|
||||
"search_start": {
|
||||
"name": "Search Start",
|
||||
"description": "Start of the search range as exact date and time. Highest priority — overrides all other start options. Defaults to now if not specified."
|
||||
},
|
||||
"search_end": {
|
||||
"name": "Search End",
|
||||
"description": "End of the search range as exact date and time. Highest priority — overrides all other end options. Defaults to end of tomorrow if not specified."
|
||||
},
|
||||
"search_start_time": {
|
||||
"name": "Search Start Time",
|
||||
"description": "Alternative: start searching at this time of day. Combine with day offset. Ignored if Search Start (datetime) is set."
|
||||
},
|
||||
"search_start_day_offset": {
|
||||
"name": "Search Start Day Offset",
|
||||
"description": "Day offset for Search Start Time. -7 to 2: -1 = yesterday, 0 = today, 1 = tomorrow. Negative values search in the past. Only used with Search Start Time."
|
||||
},
|
||||
"search_end_time": {
|
||||
"name": "Search End Time",
|
||||
"description": "Alternative: stop searching at this time of day. Combine with day offset. Ignored if Search End (datetime) is set."
|
||||
},
|
||||
"search_end_day_offset": {
|
||||
"name": "Search End Day Offset",
|
||||
"description": "Day offset for Search End Time. -7 to 2: -1 = yesterday, 0 = today, 1 = tomorrow. Negative values search in the past. Only used with Search End Time."
|
||||
},
|
||||
"search_start_offset_minutes": {
|
||||
"name": "Search Start Offset (minutes)",
|
||||
"description": "Alternative: start searching this many minutes from now. Positive = future (60 = in 1 hour), negative = past (-60 = 1 hour ago). Ignored if Search Start or Search Start Time is set."
|
||||
},
|
||||
"search_end_offset_minutes": {
|
||||
"name": "Search End Offset (minutes)",
|
||||
"description": "Alternative: stop searching this many minutes from now. Positive = future (480 = in 8 hours), negative = past (-60 = 1 hour ago). Ignored if Search End or Search End Time is set."
|
||||
},
|
||||
"include_current_interval": {
|
||||
"name": "Include Current Interval",
|
||||
"description": "Include the currently running 15-minute interval in the search. When enabled (default), the search starts at the beginning of the current interval so it can be part of the result."
|
||||
},
|
||||
"min_segment_duration": {
|
||||
"name": "Minimum Segment Duration",
|
||||
"description": "Minimum contiguous run length. Prevents rapid on/off cycling for devices with minimum run times. Automatically rounded up to the next quarter-hour. Default: 15 minutes. Maximum: 4 hours."
|
||||
},
|
||||
"use_base_unit": {
|
||||
"name": "Use Base Currency Unit",
|
||||
"description": "Force prices in base currency (EUR, NOK) instead of the configured display unit (ct, øre). Useful for calculations."
|
||||
},
|
||||
"search_scope": {
|
||||
"name": "Search Scope",
|
||||
"description": "Shorthand for common search ranges. Overrides all other time range options. today / tomorrow = full calendar day, remaining_today = now until midnight, next_24h / next_48h = rolling window from now."
|
||||
},
|
||||
"max_price_level": {
|
||||
"name": "Maximum Price Level",
|
||||
"description": "Only consider intervals at or below this Tibber price level. very_cheap = most restrictive, very_expensive = no restriction."
|
||||
},
|
||||
"min_price_level": {
|
||||
"name": "Minimum Price Level",
|
||||
"description": "Only consider intervals at or above this Tibber price level. Useful for find_most_expensive to focus on truly expensive intervals."
|
||||
},
|
||||
"include_comparison_details": {
|
||||
"name": "Include Comparison Details",
|
||||
"description": "Enrich the price_comparison result with additional fields: comparison_price_min, comparison_price_max, and (block only) comparison_window_end."
|
||||
},
|
||||
"power_profile": {
|
||||
"name": "Power Profile",
|
||||
"description": "Variable power draw in watts per 15-minute interval. When set, estimated_total_cost reflects actual consumption instead of a flat 1 kW load. The profile is extended by repeating the last value if shorter than the window."
|
||||
}
|
||||
}
|
||||
},
|
||||
"find_most_expensive_hours": {
|
||||
"name": "Find Most Expensive Hours",
|
||||
"description": "Finds the most expensive intervals totaling a given duration, not necessarily contiguous. Useful for identifying peak price periods to avoid. Returns a schedule of intervals grouped into contiguous segments.",
|
||||
"sections": {
|
||||
"search_range": {
|
||||
"name": "Search Range",
|
||||
"description": "Define the time window to search within."
|
||||
},
|
||||
"time_alternatives": {
|
||||
"name": "Alternative Time Range Options",
|
||||
"description": "Alternative ways to define the search range using time-of-day and offsets."
|
||||
},
|
||||
"price_filter": {
|
||||
"name": "Price Level Filter",
|
||||
"description": "Restrict search to intervals within the specified price level range."
|
||||
},
|
||||
"output": {
|
||||
"name": "Output Options",
|
||||
"description": "Control cost estimation and comparison output."
|
||||
}
|
||||
},
|
||||
"fields": {
|
||||
"entry_id": {
|
||||
"name": "Entry ID",
|
||||
"description": "The config entry ID for the Tibber integration."
|
||||
},
|
||||
"duration": {
|
||||
"name": "Duration",
|
||||
"description": "Total expensive time to find. Automatically rounded up to the next quarter-hour. Maximum: 24 hours."
|
||||
},
|
||||
"search_start": {
|
||||
"name": "Search Start",
|
||||
"description": "Start of the search range as exact date and time. Highest priority — overrides all other start options. Defaults to now if not specified."
|
||||
},
|
||||
"search_end": {
|
||||
"name": "Search End",
|
||||
"description": "End of the search range as exact date and time. Highest priority — overrides all other end options. Defaults to end of tomorrow if not specified."
|
||||
},
|
||||
"search_start_time": {
|
||||
"name": "Search Start Time",
|
||||
"description": "Alternative: start searching at this time of day. Combine with day offset. Ignored if Search Start (datetime) is set."
|
||||
},
|
||||
"search_start_day_offset": {
|
||||
"name": "Search Start Day Offset",
|
||||
"description": "Day offset for Search Start Time. -7 to 2: -1 = yesterday, 0 = today, 1 = tomorrow. Negative values search in the past. Only used with Search Start Time."
|
||||
},
|
||||
"search_end_time": {
|
||||
"name": "Search End Time",
|
||||
"description": "Alternative: stop searching at this time of day. Combine with day offset. Ignored if Search End (datetime) is set."
|
||||
},
|
||||
"search_end_day_offset": {
|
||||
"name": "Search End Day Offset",
|
||||
"description": "Day offset for Search End Time. -7 to 2: -1 = yesterday, 0 = today, 1 = tomorrow. Negative values search in the past. Only used with Search End Time."
|
||||
},
|
||||
"search_start_offset_minutes": {
|
||||
"name": "Search Start Offset (minutes)",
|
||||
"description": "Alternative: start searching this many minutes from now. Positive = future (60 = in 1 hour), negative = past (-60 = 1 hour ago). Ignored if Search Start or Search Start Time is set."
|
||||
},
|
||||
"search_end_offset_minutes": {
|
||||
"name": "Search End Offset (minutes)",
|
||||
"description": "Alternative: stop searching this many minutes from now. Positive = future (480 = in 8 hours), negative = past (-60 = 1 hour ago). Ignored if Search End or Search End Time is set."
|
||||
},
|
||||
"include_current_interval": {
|
||||
"name": "Include Current Interval",
|
||||
"description": "Include the currently running 15-minute interval in the search. When enabled (default), the search starts at the beginning of the current interval so it can be part of the result."
|
||||
},
|
||||
"min_segment_duration": {
|
||||
"name": "Minimum Segment Duration",
|
||||
"description": "Minimum contiguous run length. Prevents rapid on/off cycling for devices with minimum run times. Automatically rounded up to the next quarter-hour. Default: 15 minutes. Maximum: 4 hours."
|
||||
},
|
||||
"use_base_unit": {
|
||||
"name": "Use Base Currency Unit",
|
||||
"description": "Force prices in base currency (EUR, NOK) instead of the configured display unit (ct, øre). Useful for calculations."
|
||||
},
|
||||
"search_scope": {
|
||||
"name": "Search Scope",
|
||||
"description": "Shorthand for common search ranges. Overrides all other time range options. today / tomorrow = full calendar day, remaining_today = now until midnight, next_24h / next_48h = rolling window from now."
|
||||
},
|
||||
"max_price_level": {
|
||||
"name": "Maximum Price Level",
|
||||
"description": "Only consider intervals at or below this Tibber price level. very_cheap = most restrictive, very_expensive = no restriction."
|
||||
},
|
||||
"min_price_level": {
|
||||
"name": "Minimum Price Level",
|
||||
"description": "Only consider intervals at or above this Tibber price level. Useful for find_most_expensive to focus on truly expensive intervals."
|
||||
},
|
||||
"include_comparison_details": {
|
||||
"name": "Include Comparison Details",
|
||||
"description": "Enrich the price_comparison result with additional fields: comparison_price_min, comparison_price_max, and (block only) comparison_window_end."
|
||||
},
|
||||
"power_profile": {
|
||||
"name": "Power Profile",
|
||||
"description": "Variable power draw in watts per 15-minute interval. When set, estimated_total_cost reflects actual consumption instead of a flat 1 kW load. The profile is extended by repeating the last value if shorter than the window."
|
||||
}
|
||||
}
|
||||
},
|
||||
"find_cheapest_schedule": {
|
||||
"name": "Find Cheapest Schedule",
|
||||
"description": "Schedules multiple appliances optimally without time overlap. Each task gets the cheapest available contiguous window; tasks are placed greedily in ascending cost order. Returns a per-task schedule with start/end times and price stats.",
|
||||
"sections": {
|
||||
"scheduling_options": {
|
||||
"name": "Scheduling Options",
|
||||
"description": "Configure tasks and gap constraints between them."
|
||||
},
|
||||
"search_range": {
|
||||
"name": "Search Range",
|
||||
"description": "Define the time window to search within."
|
||||
},
|
||||
"time_alternatives": {
|
||||
"name": "Alternative Time Range Options",
|
||||
"description": "Alternative ways to define the search range using time-of-day and offsets."
|
||||
},
|
||||
"price_filter": {
|
||||
"name": "Price Level Filter",
|
||||
"description": "Restrict search to intervals within the specified price level range."
|
||||
},
|
||||
"output": {
|
||||
"name": "Output Options",
|
||||
"description": "Control output currency unit."
|
||||
}
|
||||
},
|
||||
"fields": {
|
||||
"entry_id": {
|
||||
"name": "Entry ID",
|
||||
"description": "The config entry ID for the Tibber integration."
|
||||
},
|
||||
"tasks": {
|
||||
"name": "Tasks",
|
||||
"description": "List of tasks to schedule. Each task requires name (string) and duration (hh:mm:ss). Optionally add power_profile (list of watts per 15-min interval). Maximum 4 tasks."
|
||||
},
|
||||
"gap_minutes": {
|
||||
"name": "Gap Between Tasks (minutes)",
|
||||
"description": "Minimum gap in minutes between consecutive scheduled tasks. Rounded up to 15 minutes. Default: 0 (no gap)."
|
||||
},
|
||||
"search_scope": {
|
||||
"name": "Search Scope",
|
||||
"description": "Shorthand for common search ranges. Overrides all other time range options. today / tomorrow = full calendar day, remaining_today = now until midnight, next_24h / next_48h = rolling window from now."
|
||||
},
|
||||
"search_start": {
|
||||
"name": "Search Start",
|
||||
"description": "Start of the search range as exact date and time. Highest priority — overrides all other start options. Defaults to now if not specified."
|
||||
},
|
||||
"search_end": {
|
||||
"name": "Search End",
|
||||
"description": "End of the search range as exact date and time. Highest priority — overrides all other end options. Defaults to end of tomorrow if not specified."
|
||||
},
|
||||
"search_start_time": {
|
||||
"name": "Search Start Time",
|
||||
"description": "Alternative: start searching at this time of day. Combine with day offset. Ignored if Search Start (datetime) is set."
|
||||
},
|
||||
"search_start_day_offset": {
|
||||
"name": "Search Start Day Offset",
|
||||
"description": "Day offset for Search Start Time. -7 to 2: -1 = yesterday, 0 = today, 1 = tomorrow. Only used with Search Start Time."
|
||||
},
|
||||
"search_end_time": {
|
||||
"name": "Search End Time",
|
||||
"description": "Alternative: stop searching at this time of day. Combine with day offset. Ignored if Search End (datetime) is set."
|
||||
},
|
||||
"search_end_day_offset": {
|
||||
"name": "Search End Day Offset",
|
||||
"description": "Day offset for Search End Time. -7 to 2: -1 = yesterday, 0 = today, 1 = tomorrow. Only used with Search End Time."
|
||||
},
|
||||
"search_start_offset_minutes": {
|
||||
"name": "Search Start Offset (minutes)",
|
||||
"description": "Alternative: start searching this many minutes from now. Positive = future, negative = past. Ignored if Search Start or Search Start Time is set."
|
||||
},
|
||||
"search_end_offset_minutes": {
|
||||
"name": "Search End Offset (minutes)",
|
||||
"description": "Alternative: stop searching this many minutes from now. Positive = future, negative = past. Ignored if Search End or Search End Time is set."
|
||||
},
|
||||
"include_current_interval": {
|
||||
"name": "Include Current Interval",
|
||||
"description": "Include the currently running 15-minute interval in the search."
|
||||
},
|
||||
"max_price_level": {
|
||||
"name": "Maximum Price Level",
|
||||
"description": "Only consider intervals at or below this Tibber price level. very_cheap = most restrictive, very_expensive = no restriction."
|
||||
},
|
||||
"min_price_level": {
|
||||
"name": "Minimum Price Level",
|
||||
"description": "Only consider intervals at or above this Tibber price level. Useful for find_most_expensive to focus on truly expensive intervals."
|
||||
},
|
||||
"use_base_unit": {
|
||||
"name": "Use Base Currency Unit",
|
||||
"description": "Force prices in base currency (EUR, NOK) instead of the configured display unit (ct, øre). Useful for calculations."
|
||||
}
|
||||
}
|
||||
},
|
||||
"debug_clear_tomorrow": {
|
||||
"name": "Debug: Clear Tomorrow Data",
|
||||
"description": "DEBUG/TESTING: Removes tomorrow's price data from the interval pool cache. Use this to test the tomorrow data refresh cycle without waiting for the next day. After calling this service, the lifecycle sensor will show 'searching_tomorrow' (after 13:00) and the next Timer #1 cycle will fetch new data from the API.",
|
||||
|
|
@ -1371,6 +1954,15 @@
|
|||
"median": "Median",
|
||||
"mean": "Arithmetic Mean"
|
||||
}
|
||||
},
|
||||
"search_scope": {
|
||||
"options": {
|
||||
"today": "Today",
|
||||
"tomorrow": "Tomorrow",
|
||||
"remaining_today": "Remaining Today",
|
||||
"next_24h": "Next 24 Hours",
|
||||
"next_48h": "Next 48 Hours"
|
||||
}
|
||||
}
|
||||
},
|
||||
"title": "Tibber Price Information & Ratings"
|
||||
|
|
|
|||
|
|
@ -59,15 +59,7 @@
|
|||
"home_already_configured": "Dette hjemmet er allerede konfigurert i en annen oppføring. Hvert hjem kan kun konfigureres én gang.",
|
||||
"no_active_subscription": "Dette hjemmet har ikke en aktiv Tibber-kontrakt. Bare hjem med aktive strømkontrakter kan legges til Home Assistant.",
|
||||
"subscription_expired": "Tibber-kontrakten for dette hjemmet har utløpt. Bare hjem med aktive eller fremtidige strømkontrakter kan legges til Home Assistant.",
|
||||
"future_subscription_warning": "Merk: Tibber-kontrakten for dette hjemmet har ikke startet ennå. Funksjonaliteten kan være begrenset til kontrakten blir aktiv.",
|
||||
"invalid_yaml_syntax": "Ugyldig YAML-syntaks. Vennligst sjekk innrykk, kolon og spesialtegn.",
|
||||
"invalid_yaml_structure": "YAML må være en ordbok/objekt (nøkkel: verdi-par), ikke en liste eller ren tekst.",
|
||||
"service_call_failed": "Service-kall validering feilet: {error_detail}",
|
||||
"missing_entry_id": "Oppførings-ID er påkrevd, men ble ikke oppgitt.",
|
||||
"invalid_entry_id": "Ugyldig oppførings-ID eller oppføring ikke funnet.",
|
||||
"missing_home_id": "Hjem-ID mangler fra konfigurasjonsoppføringen.",
|
||||
"user_data_not_available": "Brukerdata er ikke tilgjengelig. Vennligst oppdater brukerdata først.",
|
||||
"price_fetch_failed": "Kunne ikke hente prisdata. Vennligst sjekk loggene for detaljer."
|
||||
"future_subscription_warning": "Merk: Tibber-kontrakten for dette hjemmet har ikke startet ennå. Funksjonaliteten kan være begrenset til kontrakten blir aktiv."
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "Alle tilgjengelige Tibber-hjem er allerede konfigurert. Hvert hjem kan kun konfigureres én gang.",
|
||||
|
|
@ -246,6 +238,20 @@
|
|||
"min_periods_best": "Minimumsantall beste pris-perioder å sikte på per dag. Filtre vil bli lempet steg for steg for å forsøke å oppnå dette antallet. Kun aktiv når 'Oppnå minimumsantall' er aktivert. Standard: 1",
|
||||
"relaxation_attempts_best": "Hvor mange fleksnivåer (forsøk) å prøve før man gir opp. Hvert forsøk kjører alle filterkombinasjoner på det nye fleksnivået. Flere forsøk øker sjansen for å finne flere perioder på bekostning av lengre behandlingstid."
|
||||
}
|
||||
},
|
||||
"extension_settings": {
|
||||
"name": "Utvidelse av perioderender",
|
||||
"description": "Utvid eventuelt oppdagede bestprisperioder ved begge ender for å inkludere tilstøtende svært billige intervaller.",
|
||||
"data": {
|
||||
"best_price_extend_to_very_cheap": "Utvid til svært billige intervaller",
|
||||
"best_price_max_extension_intervals": "Maksimale utvidelsesintervaller",
|
||||
"best_price_geometric_flex": "Geometrisk fleksbonus"
|
||||
},
|
||||
"data_description": {
|
||||
"best_price_extend_to_very_cheap": "Når aktivert, utvider oppdagede bestprisperioder seg utover for å inkludere tilstøtende intervaller med prisnivået 'Svært billig'. Dette fanger opp ekstremt billige intervaller ved kantene av oppdagede perioder.",
|
||||
"best_price_max_extension_intervals": "Maksimalt antall ekstra intervaller per side (venstre og høyre kant). Hvert intervall er 15 minutter. Eksempel: 4 intervaller = opptil 1 times utvidelse per kant. Standard: 4",
|
||||
"best_price_geometric_flex": "Ekstra fleksprosent for intervaller som faller innenfor en oppdaget prisdal (V-form). Når et dal-mønster oppdages for dagen, får intervaller innen dalsonen denne ekstra toleransen, slik at periodevarslingssystemet er mer tilbøyelig til å inkludere dem. 0 = deaktivert. Standard: 0"
|
||||
}
|
||||
}
|
||||
},
|
||||
"submit": "↩ Lagre & tilbake"
|
||||
|
|
@ -293,6 +299,20 @@
|
|||
"min_periods_peak": "Minimum antall topp-pris-perioder å sikte mot per dag. Filtre vil bli lempet trinn for trinn for å prøve å oppnå dette antallet. Kun aktiv når 'Prøv å oppnå minimum antall perioder' er aktivert. Standard: 1",
|
||||
"relaxation_attempts_peak": "Hvor mange fleksnivåer (forsøk) som testes før vi gir opp. Hvert forsøk kjører alle filterkombinasjoner på det nye fleksnivået. Flere forsøk øker sjansen for ekstra toppprisperioder, men tar litt lengre tid."
|
||||
}
|
||||
},
|
||||
"extension_settings": {
|
||||
"name": "Utvidelse av perioderender",
|
||||
"description": "Utvid eventuelt oppdagede topprisperioder ved begge ender for å inkludere tilstøtende svært dyre intervaller.",
|
||||
"data": {
|
||||
"peak_price_extend_to_very_expensive": "Utvid til svært dyre intervaller",
|
||||
"peak_price_max_extension_intervals": "Maksimale utvidelsesintervaller",
|
||||
"peak_price_geometric_flex": "Geometrisk fleksbonus"
|
||||
},
|
||||
"data_description": {
|
||||
"peak_price_extend_to_very_expensive": "Når aktivert, utvider oppdagede topprisperioder seg utover for å inkludere tilstøtende intervaller med prisnivået 'Svært dyrt'. Dette fanger opp ekstremt dyre intervaller ved kantene av oppdagede perioder.",
|
||||
"peak_price_max_extension_intervals": "Maksimalt antall ekstra intervaller per side (venstre og høyre kant). Hvert intervall er 15 minutter. Eksempel: 4 intervaller = opptil 1 times utvidelse per kant. Standard: 4",
|
||||
"peak_price_geometric_flex": "Ekstra fleksprosent for intervaller som faller innenfor en oppdaget pristopp (Λ-form). Når et topp-mønster oppdages for dagen, får intervaller innen toppsonene denne ekstra toleransen, slik at periodevarslingssystemet er mer tilbøyelig til å inkludere dem. 0 = deaktivert. Standard: 0"
|
||||
}
|
||||
}
|
||||
},
|
||||
"submit": "↩ Lagre & tilbake"
|
||||
|
|
@ -945,6 +965,45 @@
|
|||
"unknown": "Ukjent"
|
||||
}
|
||||
},
|
||||
"day_pattern_yesterday": {
|
||||
"name": "Prismønster i går",
|
||||
"state": {
|
||||
"valley": "Dal",
|
||||
"peak": "Topp",
|
||||
"double_valley": "Dobbel dal",
|
||||
"double_peak": "Dobbel topp",
|
||||
"flat": "Flat",
|
||||
"rising": "Stigende",
|
||||
"falling": "Fallende",
|
||||
"mixed": "Blandet"
|
||||
}
|
||||
},
|
||||
"day_pattern_today": {
|
||||
"name": "Prismønster i dag",
|
||||
"state": {
|
||||
"valley": "Dal",
|
||||
"peak": "Topp",
|
||||
"double_valley": "Dobbel dal",
|
||||
"double_peak": "Dobbel topp",
|
||||
"flat": "Flat",
|
||||
"rising": "Stigende",
|
||||
"falling": "Fallende",
|
||||
"mixed": "Blandet"
|
||||
}
|
||||
},
|
||||
"day_pattern_tomorrow": {
|
||||
"name": "Prismønster i morgen",
|
||||
"state": {
|
||||
"valley": "Dal",
|
||||
"peak": "Topp",
|
||||
"double_valley": "Dobbel dal",
|
||||
"double_peak": "Dobbel topp",
|
||||
"flat": "Flat",
|
||||
"rising": "Stigende",
|
||||
"falling": "Fallende",
|
||||
"mixed": "Blandet"
|
||||
}
|
||||
},
|
||||
"chart_data_export": {
|
||||
"name": "Diagramdataeksport",
|
||||
"state": {
|
||||
|
|
@ -1055,6 +1114,62 @@
|
|||
"description": "Denne oppdateringen inkluderer endringer som ble brukt automatisk.\n\n**Omdøpte entiteter ({count})**\n\nFølgende entity-nøkler ble omdøpt. Dine eksisterende entity-ID-er og automatiseringer forblir intakte:\n\n{entity_list}\n\n**Endrede varighetssensorverdier**\n\nAlle varighetssensorer (gjenværende tid, starter om, periodevarighet, trendendrings-nedtelling) rapporterer nå tilstandsverdien i **minutter** i stedet for timer. Visningsenheten i dashboards forblir timer som standard.\n\nHvis du har automatiseringer med numeriske sammenligninger på disse sensorene, oppdater tersklene:\n- Gammelt: `state < 0.25` (15 minutter som timer)\n- Nytt: `state < 15` (15 minutter)\n\nAvvis dette varselet etter å ha gjennomgått automatiseringene dine."
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"no_entries_found": {
|
||||
"message": "Ingen Tibber Prices integrasjonsoppføringer funnet. Vennligst sett opp integrasjonen først."
|
||||
},
|
||||
"multiple_entries_no_entry_id": {
|
||||
"message": "Flere Tibber Prices oppføringer funnet. Vennligst angi 'entry_id' for å velge hvilken oppføring som skal brukes."
|
||||
},
|
||||
"invalid_entry_id": {
|
||||
"message": "Ugyldig eller utilgjengelig konfigurasjonsoppføring. Vennligst sjekk oppførings-ID-en og sørg for at integrasjonen er lastet."
|
||||
},
|
||||
"missing_home_id": {
|
||||
"message": "Hjemme-ID ikke funnet i konfigurasjonsoppføringen. Vennligst rekonfigurer integrasjonen."
|
||||
},
|
||||
"user_data_not_available": {
|
||||
"message": "Brukerdata er ikke tilgjengelig ennå. Vennligst vent til den første dataoppdateringen er fullført."
|
||||
},
|
||||
"timezone_not_found": {
|
||||
"message": "Kunne ikke bestemme hjemmets tidssone. Vennligst sjekk hjemmekonfigurasjonen i Tibber-kontoen din."
|
||||
},
|
||||
"end_before_start": {
|
||||
"message": "Sluttid må være etter starttid."
|
||||
},
|
||||
"price_fetch_failed": {
|
||||
"message": "Kunne ikke hente prisdata fra Tibber API. Vennligst prøv igjen senere."
|
||||
},
|
||||
"invalid_search_scope": {
|
||||
"message": "Ugyldig søkeområde. Gyldige verdier er: today, tomorrow, remaining_today, next_24h, next_48h."
|
||||
},
|
||||
"scope_conflicts_with_range": {
|
||||
"message": "search_scope kan ikke kombineres med eksplisitte områdeparametre: {params}. Bruk enten search_scope ELLER eksplisitte start-/sluttparametre."
|
||||
},
|
||||
"day_offset_requires_time": {
|
||||
"message": "{offset_param} krever at {time_param} er satt. Dagsforskyvning endrer kun datoen til en eksplisitt tidsparameter."
|
||||
},
|
||||
"min_level_exceeds_max": {
|
||||
"message": "min_price_level '{min_level}' er høyere enn max_price_level '{max_level}'. Minimumsnivået må være lik eller lavere enn maksimumsnivået."
|
||||
},
|
||||
"power_profile_length_mismatch": {
|
||||
"message": "power_profile har {profile_length} oppføringer, men varigheten krever {interval_count} intervaller ({duration_minutes} minutter). power_profile må ha nøyaktig én oppføring per 15-minutters intervall."
|
||||
},
|
||||
"level_and_rating_filter_conflict": {
|
||||
"message": "level_filter og rating_level_filter kan ikke brukes sammen. Bruk kun én filtertype per forespørsel."
|
||||
},
|
||||
"insert_nulls_requires_filter": {
|
||||
"message": "insert_nulls-modus '{mode}' krever en level_filter eller rating_level_filter for å definere segmenter. Uten filter, bruk insert_nulls: none."
|
||||
},
|
||||
"connect_segments_requires_segments_mode": {
|
||||
"message": "connect_segments krever at insert_nulls er satt til 'segments'. Sett insert_nulls: segments for å bruke segmentforbindelse."
|
||||
},
|
||||
"array_fields_requires_array_format": {
|
||||
"message": "array_fields kan kun brukes med output_format: array_of_arrays. Endre utdataformatet eller fjern array_fields."
|
||||
},
|
||||
"invalid_array_fields": {
|
||||
"message": "Ugyldig array_fields-mal. Bruk feltnavn i krøllparenteser, f.eks. '{start_time}, {price_per_kwh}, {level}'."
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"get_price": {
|
||||
"name": "Hent prisdata",
|
||||
|
|
@ -1261,6 +1376,474 @@
|
|||
"description": "Konfig-oppførings-ID for Tibber-integrasjonen."
|
||||
}
|
||||
}
|
||||
},
|
||||
"find_cheapest_block": {
|
||||
"name": "Finn billigste blokk",
|
||||
"description": "Finner det billigste sammenhengende tidsvinduet med en gitt varighet. Designet for apparatplanlegging: oppvaskmaskin, vaskemaskin, tørketrommel osv. Returnerer det billigste vinduet med start-/sluttider og prisstatistikk.",
|
||||
"sections": {
|
||||
"search_range": {
|
||||
"name": "Soekeomraade",
|
||||
"description": "Definer tidsvinduet for soeket."
|
||||
},
|
||||
"time_alternatives": {
|
||||
"name": "Alternative tidsalternativer",
|
||||
"description": "Alternative maater aa definere soekeomraadet paa med tidspunkt og offsets."
|
||||
},
|
||||
"price_filter": {
|
||||
"name": "Prisnivaae-filter",
|
||||
"description": "Begrens soeket til intervaller innenfor det angitte prisnivaae-omraadet."
|
||||
},
|
||||
"output": {
|
||||
"name": "Utdata-alternativer",
|
||||
"description": "Styr kostnadsestimat og sammenligningsutdata."
|
||||
}
|
||||
},
|
||||
"fields": {
|
||||
"entry_id": {
|
||||
"name": "Oppførings-ID",
|
||||
"description": "Konfig-oppførings-ID for Tibber-integrasjonen."
|
||||
},
|
||||
"duration": {
|
||||
"name": "Varighet",
|
||||
"description": "Lengden på det ønskede sammenhengende vinduet. Rundes automatisk opp til nærmeste kvarter. Maksimum: 12 timer."
|
||||
},
|
||||
"search_start": {
|
||||
"name": "Søkestart",
|
||||
"description": "Start av søkeområdet som eksakt dato og tid. Høyeste prioritet — overstyrer alle andre startalternativer. Standard er nå hvis ikke angitt."
|
||||
},
|
||||
"search_end": {
|
||||
"name": "Søkeslutt",
|
||||
"description": "Slutt av søkeområdet som eksakt dato og tid. Høyeste prioritet — overstyrer alle andre sluttalternativer. Standard er slutten av i morgen hvis ikke angitt."
|
||||
},
|
||||
"search_start_time": {
|
||||
"name": "Søkestart-klokkeslett",
|
||||
"description": "Alternativ: Start søk fra dette klokkeslettet. Kombiner med dagsforskyvning. Ignoreres hvis Søkestart (dato/tid) er satt."
|
||||
},
|
||||
"search_start_day_offset": {
|
||||
"name": "Søkestart dagsforskyvning",
|
||||
"description": "Dagsforskyvning for Søkestart-klokkeslett. -7 til 2: -1 = i går, 0 = i dag, 1 = i morgen. Negative verdier søker i fortiden. Brukes kun med Søkestart-klokkeslett."
|
||||
},
|
||||
"search_end_time": {
|
||||
"name": "Søkeslutt-klokkeslett",
|
||||
"description": "Alternativ: Søk til dette klokkeslettet. Kombiner med dagsforskyvning. Ignoreres hvis Søkeslutt (dato/tid) er satt."
|
||||
},
|
||||
"search_end_day_offset": {
|
||||
"name": "Søkeslutt dagsforskyvning",
|
||||
"description": "Dagsforskyvning for Søkeslutt-klokkeslett. -7 til 2: -1 = i går, 0 = i dag, 1 = i morgen. Negative verdier søker i fortiden. Brukes kun med Søkeslutt-klokkeslett."
|
||||
},
|
||||
"search_start_offset_minutes": {
|
||||
"name": "Søkestart-forskyvning (minutter)",
|
||||
"description": "Alternativ: Start søk dette antall minutter fra nå. Positiv = fremtid (60 = om 1 time), negativ = fortid (-60 = 1 time siden). Ignoreres hvis Søkestart eller Søkestart-klokkeslett er satt."
|
||||
},
|
||||
"search_end_offset_minutes": {
|
||||
"name": "Søkeslutt-forskyvning (minutter)",
|
||||
"description": "Alternativ: Stopp søk dette antall minutter fra nå. Positiv = fremtid (480 = om 8 timer), negativ = fortid (-60 = 1 time siden). Ignoreres hvis Søkeslutt eller Søkeslutt-klokkeslett er satt."
|
||||
},
|
||||
"include_current_interval": {
|
||||
"name": "Inkluder gjeldende intervall",
|
||||
"description": "Inkluder det pågående 15-minutters intervallet i søket. Når aktivert (standard), starter søket ved begynnelsen av gjeldende intervall slik at det kan være en del av resultatet."
|
||||
},
|
||||
"use_base_unit": {
|
||||
"name": "Bruk basisvaluta",
|
||||
"description": "Tving priser i basisvaluta (EUR, NOK) i stedet for konfigurert visningsenhet (ct, øre). Nyttig for beregninger."
|
||||
},
|
||||
"search_scope": {
|
||||
"name": "Soekeomfang (snarvei)",
|
||||
"description": "Snarvei for vanlige soekeomraader. Overstyrer alle andre tidsalternativer. today/tomorrow = hele kalenderdagen, remaining_today = naa til midnatt, next_24h/next_48h = rullende vindu fra naa."
|
||||
},
|
||||
"max_price_level": {
|
||||
"name": "Maksimalt prisnivaae",
|
||||
"description": "Ta bare med intervaller paa eller under dette Tibber-prisnivaeet. very_cheap = mest restriktivt, very_expensive = ingen begrensning."
|
||||
},
|
||||
"min_price_level": {
|
||||
"name": "Minimalt prisnivaae",
|
||||
"description": "Ta bare med intervaller paa eller over dette Tibber-prisnivaeet. Nyttig for find_most_expensive for aa fokusere paa virkelig dyre intervaller."
|
||||
},
|
||||
"include_comparison_details": {
|
||||
"name": "Inkluder sammenligningsdetaljer",
|
||||
"description": "Berik price_comparison-resultatet med tilleggsfelter: comparison_price_min, comparison_price_max og (kun blokk) comparison_window_end."
|
||||
},
|
||||
"power_profile": {
|
||||
"name": "Effektprofil",
|
||||
"description": "Variabelt effektforbruk i watt per 15-minuttersintervall. Naa satt, gjenspeiler estimated_total_cost faktisk forbruk i stedet for en fast 1 kW-last."
|
||||
}
|
||||
}
|
||||
},
|
||||
"find_most_expensive_block": {
|
||||
"name": "Finn dyreste blokk",
|
||||
"description": "Finner det dyreste sammenhengende tidsvinduet med en gitt varighet. Nyttig for å identifisere topprisperioder som bør unngås. Returnerer det dyreste vinduet med start-/sluttider og prisstatistikk.",
|
||||
"sections": {
|
||||
"search_range": {
|
||||
"name": "Soekeomraade",
|
||||
"description": "Definer tidsvinduet for soeket."
|
||||
},
|
||||
"time_alternatives": {
|
||||
"name": "Alternative tidsalternativer",
|
||||
"description": "Alternative maater aa definere soekeomraadet paa med tidspunkt og offsets."
|
||||
},
|
||||
"price_filter": {
|
||||
"name": "Prisnivaae-filter",
|
||||
"description": "Begrens soeket til intervaller innenfor det angitte prisnivaae-omraadet."
|
||||
},
|
||||
"output": {
|
||||
"name": "Utdata-alternativer",
|
||||
"description": "Styr kostnadsestimat og sammenligningsutdata."
|
||||
}
|
||||
},
|
||||
"fields": {
|
||||
"entry_id": {
|
||||
"name": "Oppførings-ID",
|
||||
"description": "Konfig-oppførings-ID for Tibber-integrasjonen."
|
||||
},
|
||||
"duration": {
|
||||
"name": "Varighet",
|
||||
"description": "Lengden på det ønskede sammenhengende vinduet. Rundes automatisk opp til nærmeste kvarter. Maksimum: 12 timer."
|
||||
},
|
||||
"search_start": {
|
||||
"name": "Søkestart",
|
||||
"description": "Start av søkeområdet som eksakt dato og tid. Høyeste prioritet — overstyrer alle andre startalternativer. Standard er nå hvis ikke angitt."
|
||||
},
|
||||
"search_end": {
|
||||
"name": "Søkeslutt",
|
||||
"description": "Slutt av søkeområdet som eksakt dato og tid. Høyeste prioritet — overstyrer alle andre sluttalternativer. Standard er slutten av i morgen hvis ikke angitt."
|
||||
},
|
||||
"search_start_time": {
|
||||
"name": "Søkestart-klokkeslett",
|
||||
"description": "Alternativ: Start søk fra dette klokkeslettet. Kombiner med dagsforskyvning. Ignoreres hvis Søkestart (dato/tid) er satt."
|
||||
},
|
||||
"search_start_day_offset": {
|
||||
"name": "Søkestart dagsforskyvning",
|
||||
"description": "Dagsforskyvning for Søkestart-klokkeslett. -7 til 2: -1 = i går, 0 = i dag, 1 = i morgen. Negative verdier søker i fortiden. Brukes kun med Søkestart-klokkeslett."
|
||||
},
|
||||
"search_end_time": {
|
||||
"name": "Søkeslutt-klokkeslett",
|
||||
"description": "Alternativ: Søk til dette klokkeslettet. Kombiner med dagsforskyvning. Ignoreres hvis Søkeslutt (dato/tid) er satt."
|
||||
},
|
||||
"search_end_day_offset": {
|
||||
"name": "Søkeslutt dagsforskyvning",
|
||||
"description": "Dagsforskyvning for Søkeslutt-klokkeslett. -7 til 2: -1 = i går, 0 = i dag, 1 = i morgen. Negative verdier søker i fortiden. Brukes kun med Søkeslutt-klokkeslett."
|
||||
},
|
||||
"search_start_offset_minutes": {
|
||||
"name": "Søkestart-forskyvning (minutter)",
|
||||
"description": "Alternativ: Start søk dette antall minutter fra nå. Positiv = fremtid (60 = om 1 time), negativ = fortid (-60 = 1 time siden). Ignoreres hvis Søkestart eller Søkestart-klokkeslett er satt."
|
||||
},
|
||||
"search_end_offset_minutes": {
|
||||
"name": "Søkeslutt-forskyvning (minutter)",
|
||||
"description": "Alternativ: Stopp søk dette antall minutter fra nå. Positiv = fremtid (480 = om 8 timer), negativ = fortid (-60 = 1 time siden). Ignoreres hvis Søkeslutt eller Søkeslutt-klokkeslett er satt."
|
||||
},
|
||||
"include_current_interval": {
|
||||
"name": "Inkluder gjeldende intervall",
|
||||
"description": "Inkluder det pågående 15-minutters intervallet i søket. Når aktivert (standard), starter søket ved begynnelsen av gjeldende intervall slik at det kan være en del av resultatet."
|
||||
},
|
||||
"use_base_unit": {
|
||||
"name": "Bruk basisvaluta",
|
||||
"description": "Tving priser i basisvaluta (EUR, NOK) i stedet for konfigurert visningsenhet (ct, øre). Nyttig for beregninger."
|
||||
},
|
||||
"search_scope": {
|
||||
"name": "Soekeomfang (snarvei)",
|
||||
"description": "Snarvei for vanlige soekeomraader. Overstyrer alle andre tidsalternativer. today/tomorrow = hele kalenderdagen, remaining_today = naa til midnatt, next_24h/next_48h = rullende vindu fra naa."
|
||||
},
|
||||
"max_price_level": {
|
||||
"name": "Maksimalt prisnivaae",
|
||||
"description": "Ta bare med intervaller paa eller under dette Tibber-prisnivaeet. very_cheap = mest restriktivt, very_expensive = ingen begrensning."
|
||||
},
|
||||
"min_price_level": {
|
||||
"name": "Minimalt prisnivaae",
|
||||
"description": "Ta bare med intervaller paa eller over dette Tibber-prisnivaeet. Nyttig for find_most_expensive for aa fokusere paa virkelig dyre intervaller."
|
||||
},
|
||||
"include_comparison_details": {
|
||||
"name": "Inkluder sammenligningsdetaljer",
|
||||
"description": "Berik price_comparison-resultatet med tilleggsfelter: comparison_price_min, comparison_price_max og (kun blokk) comparison_window_end."
|
||||
},
|
||||
"power_profile": {
|
||||
"name": "Effektprofil",
|
||||
"description": "Variabelt effektforbruk i watt per 15-minuttersintervall. Naa satt, gjenspeiler estimated_total_cost faktisk forbruk i stedet for en fast 1 kW-last."
|
||||
}
|
||||
}
|
||||
},
|
||||
"find_cheapest_hours": {
|
||||
"name": "Finn billigste timer",
|
||||
"description": "Finner de billigste intervallene for en gitt total varighet, ikke nødvendigvis sammenhengende. Designet for fleksible laster: batterilading, elbil, varmtvannsbereder. Returnerer en tidsplan med intervaller gruppert i sammenhengende segmenter.",
|
||||
"sections": {
|
||||
"search_range": {
|
||||
"name": "Soekeomraade",
|
||||
"description": "Definer tidsvinduet for soeket."
|
||||
},
|
||||
"time_alternatives": {
|
||||
"name": "Alternative tidsalternativer",
|
||||
"description": "Alternative maater aa definere soekeomraadet paa med tidspunkt og offsets."
|
||||
},
|
||||
"price_filter": {
|
||||
"name": "Prisnivaae-filter",
|
||||
"description": "Begrens soeket til intervaller innenfor det angitte prisnivaae-omraadet."
|
||||
},
|
||||
"output": {
|
||||
"name": "Utdata-alternativer",
|
||||
"description": "Styr kostnadsestimat og sammenligningsutdata."
|
||||
}
|
||||
},
|
||||
"fields": {
|
||||
"entry_id": {
|
||||
"name": "Oppførings-ID",
|
||||
"description": "Konfig-oppførings-ID for Tibber-integrasjonen."
|
||||
},
|
||||
"duration": {
|
||||
"name": "Varighet",
|
||||
"description": "Nødvendig billig total tid. Rundes automatisk opp til nærmeste kvarter. Maksimum: 24 timer."
|
||||
},
|
||||
"search_start": {
|
||||
"name": "Søkestart",
|
||||
"description": "Start av søkeområdet som eksakt dato og tid. Høyeste prioritet — overstyrer alle andre startalternativer. Standard er nå hvis ikke angitt."
|
||||
},
|
||||
"search_end": {
|
||||
"name": "Søkeslutt",
|
||||
"description": "Slutt av søkeområdet som eksakt dato og tid. Høyeste prioritet — overstyrer alle andre sluttalternativer. Standard er slutten av i morgen hvis ikke angitt."
|
||||
},
|
||||
"search_start_time": {
|
||||
"name": "Søkestart-klokkeslett",
|
||||
"description": "Alternativ: Start søk fra dette klokkeslettet. Kombiner med dagsforskyvning. Ignoreres hvis Søkestart (dato/tid) er satt."
|
||||
},
|
||||
"search_start_day_offset": {
|
||||
"name": "Søkestart dagsforskyvning",
|
||||
"description": "Dagsforskyvning for Søkestart-klokkeslett. -7 til 2: -1 = i går, 0 = i dag, 1 = i morgen. Negative verdier søker i fortiden. Brukes kun med Søkestart-klokkeslett."
|
||||
},
|
||||
"search_end_time": {
|
||||
"name": "Søkeslutt-klokkeslett",
|
||||
"description": "Alternativ: Søk til dette klokkeslettet. Kombiner med dagsforskyvning. Ignoreres hvis Søkeslutt (dato/tid) er satt."
|
||||
},
|
||||
"search_end_day_offset": {
|
||||
"name": "Søkeslutt dagsforskyvning",
|
||||
"description": "Dagsforskyvning for Søkeslutt-klokkeslett. -7 til 2: -1 = i går, 0 = i dag, 1 = i morgen. Negative verdier søker i fortiden. Brukes kun med Søkeslutt-klokkeslett."
|
||||
},
|
||||
"search_start_offset_minutes": {
|
||||
"name": "Søkestart-forskyvning (minutter)",
|
||||
"description": "Alternativ: Start søk dette antall minutter fra nå. Positiv = fremtid (60 = om 1 time), negativ = fortid (-60 = 1 time siden). Ignoreres hvis Søkestart eller Søkestart-klokkeslett er satt."
|
||||
},
|
||||
"search_end_offset_minutes": {
|
||||
"name": "Søkeslutt-forskyvning (minutter)",
|
||||
"description": "Alternativ: Stopp søk dette antall minutter fra nå. Positiv = fremtid (480 = om 8 timer), negativ = fortid (-60 = 1 time siden). Ignoreres hvis Søkeslutt eller Søkeslutt-klokkeslett er satt."
|
||||
},
|
||||
"include_current_interval": {
|
||||
"name": "Inkluder gjeldende intervall",
|
||||
"description": "Inkluder det pågående 15-minutters intervallet i søket. Når aktivert (standard), starter søket ved begynnelsen av gjeldende intervall slik at det kan være en del av resultatet."
|
||||
},
|
||||
"min_segment_duration": {
|
||||
"name": "Minimum segmentvarighet",
|
||||
"description": "Minimum sammenhengende kjøretid. Forhindrer rask av/på-sykling for enheter med minimum kjøretid. Rundes automatisk opp til nærmeste kvarter. Standard: 15 minutter. Maksimum: 4 timer."
|
||||
},
|
||||
"use_base_unit": {
|
||||
"name": "Bruk basisvaluta",
|
||||
"description": "Tving priser i basisvaluta (EUR, NOK) i stedet for konfigurert visningsenhet (ct, øre). Nyttig for beregninger."
|
||||
},
|
||||
"search_scope": {
|
||||
"name": "Soekeomfang (snarvei)",
|
||||
"description": "Snarvei for vanlige soekeomraader. Overstyrer alle andre tidsalternativer. today/tomorrow = hele kalenderdagen, remaining_today = naa til midnatt, next_24h/next_48h = rullende vindu fra naa."
|
||||
},
|
||||
"max_price_level": {
|
||||
"name": "Maksimalt prisnivaae",
|
||||
"description": "Ta bare med intervaller paa eller under dette Tibber-prisnivaeet. very_cheap = mest restriktivt, very_expensive = ingen begrensning."
|
||||
},
|
||||
"min_price_level": {
|
||||
"name": "Minimalt prisnivaae",
|
||||
"description": "Ta bare med intervaller paa eller over dette Tibber-prisnivaeet. Nyttig for find_most_expensive for aa fokusere paa virkelig dyre intervaller."
|
||||
},
|
||||
"include_comparison_details": {
|
||||
"name": "Inkluder sammenligningsdetaljer",
|
||||
"description": "Berik price_comparison-resultatet med tilleggsfelter: comparison_price_min, comparison_price_max og (kun blokk) comparison_window_end."
|
||||
},
|
||||
"power_profile": {
|
||||
"name": "Effektprofil",
|
||||
"description": "Variabelt effektforbruk i watt per 15-minuttersintervall. Naa satt, gjenspeiler estimated_total_cost faktisk forbruk i stedet for en fast 1 kW-last."
|
||||
}
|
||||
}
|
||||
},
|
||||
"find_most_expensive_hours": {
|
||||
"name": "Finn dyreste timer",
|
||||
"description": "Finner de dyreste intervallene for en gitt total varighet, ikke nødvendigvis sammenhengende. Nyttig for å identifisere topprisperioder som bør unngås. Returnerer en tidsplan med intervaller gruppert i sammenhengende segmenter.",
|
||||
"sections": {
|
||||
"search_range": {
|
||||
"name": "Soekeomraade",
|
||||
"description": "Definer tidsvinduet for soeket."
|
||||
},
|
||||
"time_alternatives": {
|
||||
"name": "Alternative tidsalternativer",
|
||||
"description": "Alternative maater aa definere soekeomraadet paa med tidspunkt og offsets."
|
||||
},
|
||||
"price_filter": {
|
||||
"name": "Prisnivaae-filter",
|
||||
"description": "Begrens soeket til intervaller innenfor det angitte prisnivaae-omraadet."
|
||||
},
|
||||
"output": {
|
||||
"name": "Utdata-alternativer",
|
||||
"description": "Styr kostnadsestimat og sammenligningsutdata."
|
||||
}
|
||||
},
|
||||
"fields": {
|
||||
"entry_id": {
|
||||
"name": "Oppførings-ID",
|
||||
"description": "Konfig-oppførings-ID for Tibber-integrasjonen."
|
||||
},
|
||||
"duration": {
|
||||
"name": "Varighet",
|
||||
"description": "Dyr total tid som skal finnes. Rundes automatisk opp til nærmeste kvarter. Maksimum: 24 timer."
|
||||
},
|
||||
"search_start": {
|
||||
"name": "Søkestart",
|
||||
"description": "Start av søkeområdet som eksakt dato og tid. Høyeste prioritet — overstyrer alle andre startalternativer. Standard er nå hvis ikke angitt."
|
||||
},
|
||||
"search_end": {
|
||||
"name": "Søkeslutt",
|
||||
"description": "Slutt av søkeområdet som eksakt dato og tid. Høyeste prioritet — overstyrer alle andre sluttalternativer. Standard er slutten av i morgen hvis ikke angitt."
|
||||
},
|
||||
"search_start_time": {
|
||||
"name": "Søkestart-klokkeslett",
|
||||
"description": "Alternativ: Start søk fra dette klokkeslettet. Kombiner med dagsforskyvning. Ignoreres hvis Søkestart (dato/tid) er satt."
|
||||
},
|
||||
"search_start_day_offset": {
|
||||
"name": "Søkestart dagsforskyvning",
|
||||
"description": "Dagsforskyvning for Søkestart-klokkeslett. -7 til 2: -1 = i går, 0 = i dag, 1 = i morgen. Negative verdier søker i fortiden. Brukes kun med Søkestart-klokkeslett."
|
||||
},
|
||||
"search_end_time": {
|
||||
"name": "Søkeslutt-klokkeslett",
|
||||
"description": "Alternativ: Søk til dette klokkeslettet. Kombiner med dagsforskyvning. Ignoreres hvis Søkeslutt (dato/tid) er satt."
|
||||
},
|
||||
"search_end_day_offset": {
|
||||
"name": "Søkeslutt dagsforskyvning",
|
||||
"description": "Dagsforskyvning for Søkeslutt-klokkeslett. -7 til 2: -1 = i går, 0 = i dag, 1 = i morgen. Negative verdier søker i fortiden. Brukes kun med Søkeslutt-klokkeslett."
|
||||
},
|
||||
"search_start_offset_minutes": {
|
||||
"name": "Søkestart-forskyvning (minutter)",
|
||||
"description": "Alternativ: Start søk dette antall minutter fra nå. Positiv = fremtid (60 = om 1 time), negativ = fortid (-60 = 1 time siden). Ignoreres hvis Søkestart eller Søkestart-klokkeslett er satt."
|
||||
},
|
||||
"search_end_offset_minutes": {
|
||||
"name": "Søkeslutt-forskyvning (minutter)",
|
||||
"description": "Alternativ: Stopp søk dette antall minutter fra nå. Positiv = fremtid (480 = om 8 timer), negativ = fortid (-60 = 1 time siden). Ignoreres hvis Søkeslutt eller Søkeslutt-klokkeslett er satt."
|
||||
},
|
||||
"include_current_interval": {
|
||||
"name": "Inkluder gjeldende intervall",
|
||||
"description": "Inkluder det pågående 15-minutters intervallet i søket. Når aktivert (standard), starter søket ved begynnelsen av gjeldende intervall slik at det kan være en del av resultatet."
|
||||
},
|
||||
"min_segment_duration": {
|
||||
"name": "Minimum segmentvarighet",
|
||||
"description": "Minimum sammenhengende kjøretid. Forhindrer rask av/på-sykling for enheter med minimum kjøretid. Rundes automatisk opp til nærmeste kvarter. Standard: 15 minutter. Maksimum: 4 timer."
|
||||
},
|
||||
"use_base_unit": {
|
||||
"name": "Bruk basisvaluta",
|
||||
"description": "Tving priser i basisvaluta (EUR, NOK) i stedet for konfigurert visningsenhet (ct, øre). Nyttig for beregninger."
|
||||
},
|
||||
"search_scope": {
|
||||
"name": "Soekeomfang (snarvei)",
|
||||
"description": "Snarvei for vanlige soekeomraader. Overstyrer alle andre tidsalternativer. today/tomorrow = hele kalenderdagen, remaining_today = naa til midnatt, next_24h/next_48h = rullende vindu fra naa."
|
||||
},
|
||||
"max_price_level": {
|
||||
"name": "Maksimalt prisnivaae",
|
||||
"description": "Ta bare med intervaller paa eller under dette Tibber-prisnivaeet. very_cheap = mest restriktivt, very_expensive = ingen begrensning."
|
||||
},
|
||||
"min_price_level": {
|
||||
"name": "Minimalt prisnivaae",
|
||||
"description": "Ta bare med intervaller paa eller over dette Tibber-prisnivaeet. Nyttig for find_most_expensive for aa fokusere paa virkelig dyre intervaller."
|
||||
},
|
||||
"include_comparison_details": {
|
||||
"name": "Inkluder sammenligningsdetaljer",
|
||||
"description": "Berik price_comparison-resultatet med tilleggsfelter: comparison_price_min, comparison_price_max og (kun blokk) comparison_window_end."
|
||||
},
|
||||
"power_profile": {
|
||||
"name": "Effektprofil",
|
||||
"description": "Variabelt effektforbruk i watt per 15-minuttersintervall. Naa satt, gjenspeiler estimated_total_cost faktisk forbruk i stedet for en fast 1 kW-last."
|
||||
}
|
||||
}
|
||||
},
|
||||
"find_cheapest_schedule": {
|
||||
"name": "Finn billigste tidsplan",
|
||||
"description": "Planlegger flere apparater optimalt uten tidsoverlapp. Hver oppgave tildeles det billigste tilgjengelige sammenhengende tidsvinduet.",
|
||||
"sections": {
|
||||
"scheduling_options": {
|
||||
"name": "Planleggingsalternativer",
|
||||
"description": "Konfigurer oppgaver og pause mellom dem."
|
||||
},
|
||||
"search_range": {
|
||||
"name": "Soekeomraade",
|
||||
"description": "Definer tidsvinduet for soeket."
|
||||
},
|
||||
"time_alternatives": {
|
||||
"name": "Alternative tidsalternativer",
|
||||
"description": "Alternative maater aa definere soekeomraadet paa med tidspunkt og offsets."
|
||||
},
|
||||
"price_filter": {
|
||||
"name": "Prisnivaae-filter",
|
||||
"description": "Begrens soeket til intervaller innenfor det angitte prisnivaae-omraadet."
|
||||
},
|
||||
"output": {
|
||||
"name": "Utdata-alternativer",
|
||||
"description": "Styr kostnadsestimat og sammenligningsutdata."
|
||||
}
|
||||
},
|
||||
"fields": {
|
||||
"entry_id": {
|
||||
"name": "Oppførings-ID",
|
||||
"description": "Konfig-oppførings-ID for Tibber-integrasjonen."
|
||||
},
|
||||
"tasks": {
|
||||
"name": "Oppgaver",
|
||||
"description": "Liste over oppgaver som skal planlegges. Hver oppgave trenger name (tekst) og duration (hh:mm:ss). Eventuelt power_profile (watt per 15-min-intervall). Maks 4 oppgaver."
|
||||
},
|
||||
"gap_minutes": {
|
||||
"name": "Pause mellom oppgaver (minutter)",
|
||||
"description": "Minimum pause i minutter mellom paafoeglende planlagte oppgaver. Avrundes opp til 15 minutter. Standard: 0 (ingen pause)."
|
||||
},
|
||||
"search_scope": {
|
||||
"name": "Soekeomfang (snarvei)",
|
||||
"description": "Snarvei for vanlige soekeomraader. Overstyrer alle andre tidsalternativer. today/tomorrow = hele kalenderdagen, remaining_today = naa til midnatt, next_24h/next_48h = rullende vindu fra naa."
|
||||
},
|
||||
"search_start": {
|
||||
"name": "Søkestart",
|
||||
"description": "Start av søkeområdet som eksakt dato og tid. Høyeste prioritet — overstyrer alle andre startalternativer. Standard er nå hvis ikke angitt."
|
||||
},
|
||||
"search_end": {
|
||||
"name": "Søkeslutt",
|
||||
"description": "Slutt av søkeområdet som eksakt dato og tid. Høyeste prioritet — overstyrer alle andre sluttalternativer. Standard er slutten av i morgen hvis ikke angitt."
|
||||
},
|
||||
"search_start_time": {
|
||||
"name": "Søkestart-klokkeslett",
|
||||
"description": "Alternativ: Start søk fra dette klokkeslettet. Kombiner med dagsforskyvning. Ignoreres hvis Søkestart (dato/tid) er satt."
|
||||
},
|
||||
"search_start_day_offset": {
|
||||
"name": "Søkestart dagsforskyvning",
|
||||
"description": "Dagsforskyvning for Søkestart-klokkeslett. -7 til 2: -1 = i går, 0 = i dag, 1 = i morgen. Negative verdier søker i fortiden. Brukes kun med Søkestart-klokkeslett."
|
||||
},
|
||||
"search_end_time": {
|
||||
"name": "Søkeslutt-klokkeslett",
|
||||
"description": "Alternativ: Søk til dette klokkeslettet. Kombiner med dagsforskyvning. Ignoreres hvis Søkeslutt (dato/tid) er satt."
|
||||
},
|
||||
"search_end_day_offset": {
|
||||
"name": "Søkeslutt dagsforskyvning",
|
||||
"description": "Dagsforskyvning for Søkeslutt-klokkeslett. -7 til 2: -1 = i går, 0 = i dag, 1 = i morgen. Negative verdier søker i fortiden. Brukes kun med Søkeslutt-klokkeslett."
|
||||
},
|
||||
"search_start_offset_minutes": {
|
||||
"name": "Søkestart-forskyvning (minutter)",
|
||||
"description": "Alternativ: Start søk dette antall minutter fra nå. Positiv = fremtid (60 = om 1 time), negativ = fortid (-60 = 1 time siden). Ignoreres hvis Søkestart eller Søkestart-klokkeslett er satt."
|
||||
},
|
||||
"search_end_offset_minutes": {
|
||||
"name": "Søkeslutt-forskyvning (minutter)",
|
||||
"description": "Alternativ: Stopp søk dette antall minutter fra nå. Positiv = fremtid (480 = om 8 timer), negativ = fortid (-60 = 1 time siden). Ignoreres hvis Søkeslutt eller Søkeslutt-klokkeslett er satt."
|
||||
},
|
||||
"include_current_interval": {
|
||||
"name": "Inkluder gjeldende intervall",
|
||||
"description": "Inkluder det pågående 15-minutters intervallet i søket. Når aktivert (standard), starter søket ved begynnelsen av gjeldende intervall slik at det kan være en del av resultatet."
|
||||
},
|
||||
"max_price_level": {
|
||||
"name": "Maksimalt prisnivaae",
|
||||
"description": "Ta bare med intervaller paa eller under dette Tibber-prisnivaeet. very_cheap = mest restriktivt, very_expensive = ingen begrensning."
|
||||
},
|
||||
"min_price_level": {
|
||||
"name": "Minimalt prisnivaae",
|
||||
"description": "Ta bare med intervaller paa eller over dette Tibber-prisnivaeet. Nyttig for find_most_expensive for aa fokusere paa virkelig dyre intervaller."
|
||||
},
|
||||
"use_base_unit": {
|
||||
"name": "Bruk basisvaluta",
|
||||
"description": "Tving priser i basisvaluta (EUR, NOK) i stedet for konfigurert visningsenhet (ct, øre). Nyttig for beregninger."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"selector": {
|
||||
|
|
@ -1361,6 +1944,15 @@
|
|||
"median": "Median",
|
||||
"mean": "Aritmetisk gjennomsnitt"
|
||||
}
|
||||
},
|
||||
"search_scope": {
|
||||
"options": {
|
||||
"today": "I dag",
|
||||
"tomorrow": "I morgen",
|
||||
"remaining_today": "Resten av dagen",
|
||||
"next_24h": "Neste 24 timer",
|
||||
"next_48h": "Neste 48 timer"
|
||||
}
|
||||
}
|
||||
},
|
||||
"title": "Tibber Prisinformasjon & Vurderinger"
|
||||
|
|
|
|||
|
|
@ -59,15 +59,7 @@
|
|||
"home_already_configured": "Dit huis is al geconfigureerd in een ander item. Elk huis kan slechts één keer worden geconfigureerd.",
|
||||
"no_active_subscription": "Dit huis heeft geen actief Tibber-contract. Alleen huizen met actieve elektriciteitscontracten kunnen worden toegevoegd aan Home Assistant.",
|
||||
"subscription_expired": "Het Tibber-contract voor dit huis is verlopen. Alleen huizen met actieve of toekomstige elektriciteitscontracten kunnen worden toegevoegd aan Home Assistant.",
|
||||
"future_subscription_warning": "Let op: Het Tibber-contract van dit huis is nog niet gestart. De functionaliteit kan beperkt zijn totdat het contract actief wordt.",
|
||||
"invalid_yaml_syntax": "Ongeldige YAML-syntaxis. Controleer inspringing, dubbele punten en speciale tekens.",
|
||||
"invalid_yaml_structure": "YAML moet een dictionary/object zijn (sleutel: waarde paren), geen lijst of platte tekst.",
|
||||
"service_call_failed": "Service call validatie mislukt: {error_detail}",
|
||||
"missing_entry_id": "Entry-ID is verplicht maar werd niet opgegeven.",
|
||||
"invalid_entry_id": "Ongeldige entry-ID of item niet gevonden.",
|
||||
"missing_home_id": "Huis-ID ontbreekt in het configuratie-item.",
|
||||
"user_data_not_available": "Gebruikersgegevens zijn niet beschikbaar. Ververs eerst de gebruikersgegevens.",
|
||||
"price_fetch_failed": "Ophalen van prijsgegevens mislukt. Controleer de logs voor details."
|
||||
"future_subscription_warning": "Let op: Het Tibber-contract van dit huis is nog niet gestart. De functionaliteit kan beperkt zijn totdat het contract actief wordt."
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "Alle beschikbare Tibber-huizen zijn al geconfigureerd. Elk huis kan slechts één keer worden geconfigureerd.",
|
||||
|
|
@ -246,6 +238,20 @@
|
|||
"min_periods_best": "Minimaal aantal beste prijsperiodes om per dag na te streven. Filters worden stapsgewijs versoepeld om dit aantal te proberen te bereiken. Alleen actief wanneer 'Bereik Minimum Aantal' is ingeschakeld. Standaard: 1",
|
||||
"relaxation_attempts_best": "Hoeveel flex niveaus (pogingen) te proberen voordat opgegeven wordt. Elke poging voert alle filtercombinaties uit op het nieuwe flex niveau. Meer pogingen verhogen de kans om extra periodes te vinden ten koste van langere verwerkingstijd."
|
||||
}
|
||||
},
|
||||
"extension_settings": {
|
||||
"name": "Uitbreiding aan perioderand",
|
||||
"description": "Breid gedetecteerde beste-prijsperioden eventueel uit aan beide randen om aangrenzende zeer goedkope intervallen op te nemen.",
|
||||
"data": {
|
||||
"best_price_extend_to_very_cheap": "Uitbreiden met zeer goedkope intervallen",
|
||||
"best_price_max_extension_intervals": "Maximale uitbreidingsintervallen",
|
||||
"best_price_geometric_flex": "Geometrische Flex Bonus"
|
||||
},
|
||||
"data_description": {
|
||||
"best_price_extend_to_very_cheap": "Indien ingeschakeld, breiden gedetecteerde beste-prijsperioden zich uit aan de randen om aangrenzende intervallen met prijsniveau 'Zeer goedkoop' op te nemen. Dit vangt extreem goedkope intervallen op aan de randen van gedetecteerde perioden.",
|
||||
"best_price_max_extension_intervals": "Maximaal aantal extra intervallen per kant (linker en rechter rand). Elk interval is 15 minuten. Voorbeeld: 4 intervallen = maximaal 1 uur uitbreiding per rand. Standaard: 4",
|
||||
"best_price_geometric_flex": "Extra flex percentage voor intervallen die binnen een gedetecteerde prijsdal (V-vorm) vallen. Wanneer een dal-patroon wordt gedetecteerd voor de dag, krijgen intervallen binnen de dalzone deze extra tolerantie, waardoor de periodedetector ze eerder opneemt. 0 = uitgeschakeld. Standaard: 0"
|
||||
}
|
||||
}
|
||||
},
|
||||
"submit": "↩ Opslaan & Terug"
|
||||
|
|
@ -293,6 +299,20 @@
|
|||
"min_periods_peak": "Minimaal aantal piekprijs periodes om per dag na te streven. Filters worden stapsgewijs versoepeld om dit aantal te proberen te bereiken. Alleen actief wanneer 'Bereik Minimum Aantal' is ingeschakeld. Standaard: 1",
|
||||
"relaxation_attempts_peak": "Hoeveel flex niveaus (pogingen) te proberen voordat opgegeven wordt. Elke poging voert alle filtercombinaties uit op het nieuwe flex niveau. Meer pogingen verhogen de kans om extra piekperiodes te vinden ten koste van langere verwerkingstijd."
|
||||
}
|
||||
},
|
||||
"extension_settings": {
|
||||
"name": "Uitbreiding aan perioderand",
|
||||
"description": "Breid gedetecteerde piekprijsperioden eventueel uit aan beide randen om aangrenzende zeer dure intervallen op te nemen.",
|
||||
"data": {
|
||||
"peak_price_extend_to_very_expensive": "Uitbreiden met zeer dure intervallen",
|
||||
"peak_price_max_extension_intervals": "Maximale uitbreidingsintervallen",
|
||||
"peak_price_geometric_flex": "Geometrische Flex Bonus"
|
||||
},
|
||||
"data_description": {
|
||||
"peak_price_extend_to_very_expensive": "Indien ingeschakeld, breiden gedetecteerde piekprijsperioden zich uit aan de randen om aangrenzende intervallen met prijsniveau 'Zeer duur' op te nemen. Dit vangt extreem dure intervallen op aan de randen van gedetecteerde perioden.",
|
||||
"peak_price_max_extension_intervals": "Maximaal aantal extra intervallen per kant (linker en rechter rand). Elk interval is 15 minuten. Voorbeeld: 4 intervallen = maximaal 1 uur uitbreiding per rand. Standaard: 4",
|
||||
"peak_price_geometric_flex": "Extra flex percentage voor intervallen die binnen een gedetecteerde prijspiek (Λ-vorm) vallen. Wanneer een piek-patroon wordt gedetecteerd voor de dag, krijgen intervallen binnen de piekzone deze extra tolerantie, waardoor de periodedetector ze eerder opneemt. 0 = uitgeschakeld. Standaard: 0"
|
||||
}
|
||||
}
|
||||
},
|
||||
"submit": "↩ Opslaan & Terug"
|
||||
|
|
@ -945,6 +965,45 @@
|
|||
"unknown": "Onbekend"
|
||||
}
|
||||
},
|
||||
"day_pattern_yesterday": {
|
||||
"name": "Prijspatroon Gisteren",
|
||||
"state": {
|
||||
"valley": "Dal",
|
||||
"peak": "Piek",
|
||||
"double_valley": "Dubbel Dal",
|
||||
"double_peak": "Dubbele Piek",
|
||||
"flat": "Vlak",
|
||||
"rising": "Stijgend",
|
||||
"falling": "Dalend",
|
||||
"mixed": "Gemengd"
|
||||
}
|
||||
},
|
||||
"day_pattern_today": {
|
||||
"name": "Prijspatroon Vandaag",
|
||||
"state": {
|
||||
"valley": "Dal",
|
||||
"peak": "Piek",
|
||||
"double_valley": "Dubbel Dal",
|
||||
"double_peak": "Dubbele Piek",
|
||||
"flat": "Vlak",
|
||||
"rising": "Stijgend",
|
||||
"falling": "Dalend",
|
||||
"mixed": "Gemengd"
|
||||
}
|
||||
},
|
||||
"day_pattern_tomorrow": {
|
||||
"name": "Prijspatroon Morgen",
|
||||
"state": {
|
||||
"valley": "Dal",
|
||||
"peak": "Piek",
|
||||
"double_valley": "Dubbel Dal",
|
||||
"double_peak": "Dubbele Piek",
|
||||
"flat": "Vlak",
|
||||
"rising": "Stijgend",
|
||||
"falling": "Dalend",
|
||||
"mixed": "Gemengd"
|
||||
}
|
||||
},
|
||||
"chart_data_export": {
|
||||
"name": "Grafiekdata Export",
|
||||
"state": {
|
||||
|
|
@ -1055,6 +1114,62 @@
|
|||
"description": "Deze update bevat wijzigingen die automatisch zijn toegepast.\n\n**Hernoemde entiteiten ({count})**\n\nDe volgende entity-sleutels zijn hernoemd. Je bestaande entity-ID's en automatiseringen blijven intact:\n\n{entity_list}\n\n**Gewijzigde duur-sensorwaarden**\n\nAlle duur-sensoren (resterende tijd, start over, periodeduur, trendwijzigings-aftelling) rapporteren hun statuswaarde nu in **minuten** in plaats van uren. De weergave-eenheid in dashboards blijft standaard uren.\n\nAls je automatiseringen hebt met numerieke vergelijkingen op deze sensoren, werk dan je drempelwaarden bij:\n- Oud: `state < 0.25` (15 minuten als uren)\n- Nieuw: `state < 15` (15 minuten)\n\nSluit deze melding nadat je je automatiseringen hebt gecontroleerd."
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"no_entries_found": {
|
||||
"message": "Geen Tibber Prices integratie-items gevonden. Stel de integratie eerst in."
|
||||
},
|
||||
"multiple_entries_no_entry_id": {
|
||||
"message": "Meerdere Tibber Prices items gevonden. Geef 'entry_id' op om het gewenste item te selecteren."
|
||||
},
|
||||
"invalid_entry_id": {
|
||||
"message": "Ongeldige of niet-beschikbare configuratie-item. Controleer de item-ID en zorg dat de integratie geladen is."
|
||||
},
|
||||
"missing_home_id": {
|
||||
"message": "Huis-ID niet gevonden in het configuratie-item. Configureer de integratie opnieuw."
|
||||
},
|
||||
"user_data_not_available": {
|
||||
"message": "Gebruikersgegevens zijn nog niet beschikbaar. Wacht tot de eerste gegevensupdate is voltooid."
|
||||
},
|
||||
"timezone_not_found": {
|
||||
"message": "Kon de tijdzone van het huis niet bepalen. Controleer de huisconfiguratie in je Tibber-account."
|
||||
},
|
||||
"end_before_start": {
|
||||
"message": "Eindtijd moet na de starttijd liggen."
|
||||
},
|
||||
"price_fetch_failed": {
|
||||
"message": "Kon prijsgegevens niet ophalen bij de Tibber API. Probeer het later opnieuw."
|
||||
},
|
||||
"invalid_search_scope": {
|
||||
"message": "Ongeldig zoekbereik. Geldige waarden zijn: today, tomorrow, remaining_today, next_24h, next_48h."
|
||||
},
|
||||
"scope_conflicts_with_range": {
|
||||
"message": "search_scope kan niet gecombineerd worden met expliciete bereikparameters: {params}. Gebruik search_scope OF expliciete start-/eindparameters."
|
||||
},
|
||||
"day_offset_requires_time": {
|
||||
"message": "{offset_param} vereist dat {time_param} ingesteld is. Dagoffset wijzigt alleen de datum van een expliciete tijdparameter."
|
||||
},
|
||||
"min_level_exceeds_max": {
|
||||
"message": "min_price_level '{min_level}' is hoger dan max_price_level '{max_level}'. Het minimumniveau moet gelijk aan of lager zijn dan het maximumniveau."
|
||||
},
|
||||
"power_profile_length_mismatch": {
|
||||
"message": "power_profile heeft {profile_length} items maar de duur vereist {interval_count} intervallen ({duration_minutes} minuten). Het power_profile moet precies één item per 15-minuten-interval hebben."
|
||||
},
|
||||
"level_and_rating_filter_conflict": {
|
||||
"message": "level_filter en rating_level_filter kunnen niet samen gebruikt worden. Gebruik slechts één filtertype per verzoek."
|
||||
},
|
||||
"insert_nulls_requires_filter": {
|
||||
"message": "insert_nulls-modus '{mode}' vereist een level_filter of rating_level_filter om segmenten te definiëren. Zonder filter, gebruik insert_nulls: none."
|
||||
},
|
||||
"connect_segments_requires_segments_mode": {
|
||||
"message": "connect_segments vereist dat insert_nulls op 'segments' staat. Stel insert_nulls: segments in om segmentverbinding te gebruiken."
|
||||
},
|
||||
"array_fields_requires_array_format": {
|
||||
"message": "array_fields kan alleen gebruikt worden met output_format: array_of_arrays. Wijzig het uitvoerformaat of verwijder array_fields."
|
||||
},
|
||||
"invalid_array_fields": {
|
||||
"message": "Ongeldig array_fields-sjabloon. Gebruik veldnamen tussen accolades, bijv. '{start_time}, {price_per_kwh}, {level}'."
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"get_price": {
|
||||
"name": "Prijsgegevens Ophalen",
|
||||
|
|
@ -1261,6 +1376,474 @@
|
|||
"description": "De config-item-ID voor de Tibber-integratie."
|
||||
}
|
||||
}
|
||||
},
|
||||
"find_cheapest_block": {
|
||||
"name": "Goedkoopste blok vinden",
|
||||
"description": "Vindt het goedkoopste aaneengesloten tijdvenster van een bepaalde duur. Ontworpen voor apparaatplanning: vaatwasser, wasmachine, droger enz. Retourneert het goedkoopste venster met start-/eindtijden en prijsstatistieken.",
|
||||
"sections": {
|
||||
"search_range": {
|
||||
"name": "Zoekbereik",
|
||||
"description": "Definieer het tijdvenster voor het zoeken."
|
||||
},
|
||||
"time_alternatives": {
|
||||
"name": "Alternatieve tijdbereiksopties",
|
||||
"description": "Alternatieve manieren om het zoekbereik te definieren via tijdstip en offsets."
|
||||
},
|
||||
"price_filter": {
|
||||
"name": "Prijsniveau-filter",
|
||||
"description": "Beperk het zoeken tot intervallen binnen het opgegeven prijsniveaubereik."
|
||||
},
|
||||
"output": {
|
||||
"name": "Uitvoeropties",
|
||||
"description": "Stuur kostnadsraming en vergelijkingsuitvoer."
|
||||
}
|
||||
},
|
||||
"fields": {
|
||||
"entry_id": {
|
||||
"name": "Item-ID",
|
||||
"description": "De config-item-ID voor de Tibber-integratie."
|
||||
},
|
||||
"duration": {
|
||||
"name": "Duur",
|
||||
"description": "Lengte van het gewenste aaneengesloten venster. Automatisch afgerond naar het volgende kwartier. Maximum: 12 uur."
|
||||
},
|
||||
"search_start": {
|
||||
"name": "Zoekstart",
|
||||
"description": "Begin van het zoekbereik als exacte datum en tijd. Hoogste prioriteit — overschrijft alle andere startopties. Standaard is nu als niet opgegeven."
|
||||
},
|
||||
"search_end": {
|
||||
"name": "Zoekeinde",
|
||||
"description": "Einde van het zoekbereik als exacte datum en tijd. Hoogste prioriteit — overschrijft alle andere eindopties. Standaard is einde van morgen als niet opgegeven."
|
||||
},
|
||||
"search_start_time": {
|
||||
"name": "Zoekstart-tijd",
|
||||
"description": "Alternatief: Start zoeken vanaf dit tijdstip. Combineer met dagoffset. Wordt genegeerd als Zoekstart (datum/tijd) is ingesteld."
|
||||
},
|
||||
"search_start_day_offset": {
|
||||
"name": "Zoekstart dagoffset",
|
||||
"description": "Dagoffset voor Zoekstart-tijd. -7 tot 2: -1 = gisteren, 0 = vandaag, 1 = morgen. Negatieve waarden zoeken in het verleden. Wordt alleen gebruikt met Zoekstart-tijd."
|
||||
},
|
||||
"search_end_time": {
|
||||
"name": "Zoekeinde-tijd",
|
||||
"description": "Alternatief: Zoek tot dit tijdstip. Combineer met dagoffset. Wordt genegeerd als Zoekeinde (datum/tijd) is ingesteld."
|
||||
},
|
||||
"search_end_day_offset": {
|
||||
"name": "Zoekeinde dagoffset",
|
||||
"description": "Dagoffset voor Zoekeinde-tijd. -7 tot 2: -1 = gisteren, 0 = vandaag, 1 = morgen. Negatieve waarden zoeken in het verleden. Wordt alleen gebruikt met Zoekeinde-tijd."
|
||||
},
|
||||
"search_start_offset_minutes": {
|
||||
"name": "Zoekstart-offset (minuten)",
|
||||
"description": "Alternatief: Start met zoeken over dit aantal minuten vanaf nu. Positief = toekomst (60 = over 1 uur), negatief = verleden (-60 = 1 uur geleden). Wordt genegeerd als Zoekstart of Zoekstart-tijd is ingesteld."
|
||||
},
|
||||
"search_end_offset_minutes": {
|
||||
"name": "Zoekeinde-offset (minuten)",
|
||||
"description": "Alternatief: Stop met zoeken over dit aantal minuten vanaf nu. Positief = toekomst (480 = over 8 uur), negatief = verleden (-60 = 1 uur geleden). Wordt genegeerd als Zoekeinde of Zoekeinde-tijd is ingesteld."
|
||||
},
|
||||
"include_current_interval": {
|
||||
"name": "Huidig interval opnemen",
|
||||
"description": "Het huidige lopende 15-minuten interval opnemen in de zoekopdracht. Indien ingeschakeld (standaard), begint de zoekopdracht aan het begin van het huidige interval zodat het deel kan uitmaken van het resultaat."
|
||||
},
|
||||
"use_base_unit": {
|
||||
"name": "Basisvaluta gebruiken",
|
||||
"description": "Forceer prijzen in basisvaluta (EUR, NOK) in plaats van de geconfigureerde weergave-eenheid (ct, øre). Handig voor berekeningen."
|
||||
},
|
||||
"search_scope": {
|
||||
"name": "Zoekbereik (snelkoppeling)",
|
||||
"description": "Snelkoppeling voor veelgebruikte zoekbereiken. Overschrijft alle andere tijdopties. today/tomorrow = volledige kalenderdag, remaining_today = nu tot middernacht, next_24h/next_48h = rolling venster vanaf nu."
|
||||
},
|
||||
"max_price_level": {
|
||||
"name": "Maximaal prijsniveau",
|
||||
"description": "Overweeg alleen intervallen op of onder dit Tibber-prijsniveau. very_cheap = meest restrictief, very_expensive = geen beperking."
|
||||
},
|
||||
"min_price_level": {
|
||||
"name": "Minimaal prijsniveau",
|
||||
"description": "Overweeg alleen intervallen op of boven dit Tibber-prijsniveau. Nuttig voor find_most_expensive om te focussen op echt dure intervallen."
|
||||
},
|
||||
"include_comparison_details": {
|
||||
"name": "Vergelijkingsdetails meenemen",
|
||||
"description": "Verrijk het price_comparison-resultaat met extra velden: comparison_price_min, comparison_price_max en (alleen blok) comparison_window_end."
|
||||
},
|
||||
"power_profile": {
|
||||
"name": "Vermogensprofiel",
|
||||
"description": "Variabel vermogensverbruik in watt per 15-minuten-interval. Indien ingesteld, weerspiegelt estimated_total_cost het werkelijke verbruik."
|
||||
}
|
||||
}
|
||||
},
|
||||
"find_most_expensive_block": {
|
||||
"name": "Duurste blok vinden",
|
||||
"description": "Vindt het duurste aaneengesloten tijdvenster van een bepaalde duur. Nuttig voor het identificeren van piekprijsperioden die vermeden moeten worden. Retourneert het duurste venster met start-/eindtijden en prijsstatistieken.",
|
||||
"sections": {
|
||||
"search_range": {
|
||||
"name": "Zoekbereik",
|
||||
"description": "Definieer het tijdvenster voor het zoeken."
|
||||
},
|
||||
"time_alternatives": {
|
||||
"name": "Alternatieve tijdbereiksopties",
|
||||
"description": "Alternatieve manieren om het zoekbereik te definieren via tijdstip en offsets."
|
||||
},
|
||||
"price_filter": {
|
||||
"name": "Prijsniveau-filter",
|
||||
"description": "Beperk het zoeken tot intervallen binnen het opgegeven prijsniveaubereik."
|
||||
},
|
||||
"output": {
|
||||
"name": "Uitvoeropties",
|
||||
"description": "Stuur kostnadsraming en vergelijkingsuitvoer."
|
||||
}
|
||||
},
|
||||
"fields": {
|
||||
"entry_id": {
|
||||
"name": "Item-ID",
|
||||
"description": "De config-item-ID voor de Tibber-integratie."
|
||||
},
|
||||
"duration": {
|
||||
"name": "Duur",
|
||||
"description": "Lengte van het gewenste aaneengesloten venster. Automatisch afgerond naar het volgende kwartier. Maximum: 12 uur."
|
||||
},
|
||||
"search_start": {
|
||||
"name": "Zoekstart",
|
||||
"description": "Begin van het zoekbereik als exacte datum en tijd. Hoogste prioriteit — overschrijft alle andere startopties. Standaard is nu als niet opgegeven."
|
||||
},
|
||||
"search_end": {
|
||||
"name": "Zoekeinde",
|
||||
"description": "Einde van het zoekbereik als exacte datum en tijd. Hoogste prioriteit — overschrijft alle andere eindopties. Standaard is einde van morgen als niet opgegeven."
|
||||
},
|
||||
"search_start_time": {
|
||||
"name": "Zoekstart-tijd",
|
||||
"description": "Alternatief: Start zoeken vanaf dit tijdstip. Combineer met dagoffset. Wordt genegeerd als Zoekstart (datum/tijd) is ingesteld."
|
||||
},
|
||||
"search_start_day_offset": {
|
||||
"name": "Zoekstart dagoffset",
|
||||
"description": "Dagoffset voor Zoekstart-tijd. -7 tot 2: -1 = gisteren, 0 = vandaag, 1 = morgen. Negatieve waarden zoeken in het verleden. Wordt alleen gebruikt met Zoekstart-tijd."
|
||||
},
|
||||
"search_end_time": {
|
||||
"name": "Zoekeinde-tijd",
|
||||
"description": "Alternatief: Zoek tot dit tijdstip. Combineer met dagoffset. Wordt genegeerd als Zoekeinde (datum/tijd) is ingesteld."
|
||||
},
|
||||
"search_end_day_offset": {
|
||||
"name": "Zoekeinde dagoffset",
|
||||
"description": "Dagoffset voor Zoekeinde-tijd. -7 tot 2: -1 = gisteren, 0 = vandaag, 1 = morgen. Negatieve waarden zoeken in het verleden. Wordt alleen gebruikt met Zoekeinde-tijd."
|
||||
},
|
||||
"search_start_offset_minutes": {
|
||||
"name": "Zoekstart-offset (minuten)",
|
||||
"description": "Alternatief: Start met zoeken over dit aantal minuten vanaf nu. Positief = toekomst (60 = over 1 uur), negatief = verleden (-60 = 1 uur geleden). Wordt genegeerd als Zoekstart of Zoekstart-tijd is ingesteld."
|
||||
},
|
||||
"search_end_offset_minutes": {
|
||||
"name": "Zoekeinde-offset (minuten)",
|
||||
"description": "Alternatief: Stop met zoeken over dit aantal minuten vanaf nu. Positief = toekomst (480 = over 8 uur), negatief = verleden (-60 = 1 uur geleden). Wordt genegeerd als Zoekeinde of Zoekeinde-tijd is ingesteld."
|
||||
},
|
||||
"include_current_interval": {
|
||||
"name": "Huidig interval opnemen",
|
||||
"description": "Het huidige lopende 15-minuten interval opnemen in de zoekopdracht. Indien ingeschakeld (standaard), begint de zoekopdracht aan het begin van het huidige interval zodat het deel kan uitmaken van het resultaat."
|
||||
},
|
||||
"use_base_unit": {
|
||||
"name": "Basisvaluta gebruiken",
|
||||
"description": "Forceer prijzen in basisvaluta (EUR, NOK) in plaats van de geconfigureerde weergave-eenheid (ct, øre). Handig voor berekeningen."
|
||||
},
|
||||
"search_scope": {
|
||||
"name": "Zoekbereik (snelkoppeling)",
|
||||
"description": "Snelkoppeling voor veelgebruikte zoekbereiken. Overschrijft alle andere tijdopties. today/tomorrow = volledige kalenderdag, remaining_today = nu tot middernacht, next_24h/next_48h = rolling venster vanaf nu."
|
||||
},
|
||||
"max_price_level": {
|
||||
"name": "Maximaal prijsniveau",
|
||||
"description": "Overweeg alleen intervallen op of onder dit Tibber-prijsniveau. very_cheap = meest restrictief, very_expensive = geen beperking."
|
||||
},
|
||||
"min_price_level": {
|
||||
"name": "Minimaal prijsniveau",
|
||||
"description": "Overweeg alleen intervallen op of boven dit Tibber-prijsniveau. Nuttig voor find_most_expensive om te focussen op echt dure intervallen."
|
||||
},
|
||||
"include_comparison_details": {
|
||||
"name": "Vergelijkingsdetails meenemen",
|
||||
"description": "Verrijk het price_comparison-resultaat met extra velden: comparison_price_min, comparison_price_max en (alleen blok) comparison_window_end."
|
||||
},
|
||||
"power_profile": {
|
||||
"name": "Vermogensprofiel",
|
||||
"description": "Variabel vermogensverbruik in watt per 15-minuten-interval. Indien ingesteld, weerspiegelt estimated_total_cost het werkelijke verbruik."
|
||||
}
|
||||
}
|
||||
},
|
||||
"find_cheapest_hours": {
|
||||
"name": "Goedkoopste uren vinden",
|
||||
"description": "Vindt de goedkoopste intervallen voor een bepaalde totale duur, niet noodzakelijk aaneengesloten. Ontworpen voor flexibele belastingen: batterijladen, elektrisch voertuig, warmwaterboiler. Retourneert een schema van intervallen gegroepeerd in aaneengesloten segmenten.",
|
||||
"sections": {
|
||||
"search_range": {
|
||||
"name": "Zoekbereik",
|
||||
"description": "Definieer het tijdvenster voor het zoeken."
|
||||
},
|
||||
"time_alternatives": {
|
||||
"name": "Alternatieve tijdbereiksopties",
|
||||
"description": "Alternatieve manieren om het zoekbereik te definieren via tijdstip en offsets."
|
||||
},
|
||||
"price_filter": {
|
||||
"name": "Prijsniveau-filter",
|
||||
"description": "Beperk het zoeken tot intervallen binnen het opgegeven prijsniveaubereik."
|
||||
},
|
||||
"output": {
|
||||
"name": "Uitvoeropties",
|
||||
"description": "Stuur kostnadsraming en vergelijkingsuitvoer."
|
||||
}
|
||||
},
|
||||
"fields": {
|
||||
"entry_id": {
|
||||
"name": "Item-ID",
|
||||
"description": "De config-item-ID voor de Tibber-integratie."
|
||||
},
|
||||
"duration": {
|
||||
"name": "Duur",
|
||||
"description": "Benodigde goedkope totale tijd. Automatisch afgerond naar het volgende kwartier. Maximum: 24 uur."
|
||||
},
|
||||
"search_start": {
|
||||
"name": "Zoekstart",
|
||||
"description": "Begin van het zoekbereik als exacte datum en tijd. Hoogste prioriteit — overschrijft alle andere startopties. Standaard is nu als niet opgegeven."
|
||||
},
|
||||
"search_end": {
|
||||
"name": "Zoekeinde",
|
||||
"description": "Einde van het zoekbereik als exacte datum en tijd. Hoogste prioriteit — overschrijft alle andere eindopties. Standaard is einde van morgen als niet opgegeven."
|
||||
},
|
||||
"search_start_time": {
|
||||
"name": "Zoekstart-tijd",
|
||||
"description": "Alternatief: Start zoeken vanaf dit tijdstip. Combineer met dagoffset. Wordt genegeerd als Zoekstart (datum/tijd) is ingesteld."
|
||||
},
|
||||
"search_start_day_offset": {
|
||||
"name": "Zoekstart dagoffset",
|
||||
"description": "Dagoffset voor Zoekstart-tijd. -7 tot 2: -1 = gisteren, 0 = vandaag, 1 = morgen. Negatieve waarden zoeken in het verleden. Wordt alleen gebruikt met Zoekstart-tijd."
|
||||
},
|
||||
"search_end_time": {
|
||||
"name": "Zoekeinde-tijd",
|
||||
"description": "Alternatief: Zoek tot dit tijdstip. Combineer met dagoffset. Wordt genegeerd als Zoekeinde (datum/tijd) is ingesteld."
|
||||
},
|
||||
"search_end_day_offset": {
|
||||
"name": "Zoekeinde dagoffset",
|
||||
"description": "Dagoffset voor Zoekeinde-tijd. -7 tot 2: -1 = gisteren, 0 = vandaag, 1 = morgen. Negatieve waarden zoeken in het verleden. Wordt alleen gebruikt met Zoekeinde-tijd."
|
||||
},
|
||||
"search_start_offset_minutes": {
|
||||
"name": "Zoekstart-offset (minuten)",
|
||||
"description": "Alternatief: Start met zoeken over dit aantal minuten vanaf nu. Positief = toekomst (60 = over 1 uur), negatief = verleden (-60 = 1 uur geleden). Wordt genegeerd als Zoekstart of Zoekstart-tijd is ingesteld."
|
||||
},
|
||||
"search_end_offset_minutes": {
|
||||
"name": "Zoekeinde-offset (minuten)",
|
||||
"description": "Alternatief: Stop met zoeken over dit aantal minuten vanaf nu. Positief = toekomst (480 = over 8 uur), negatief = verleden (-60 = 1 uur geleden). Wordt genegeerd als Zoekeinde of Zoekeinde-tijd is ingesteld."
|
||||
},
|
||||
"include_current_interval": {
|
||||
"name": "Huidig interval opnemen",
|
||||
"description": "Het huidige lopende 15-minuten interval opnemen in de zoekopdracht. Indien ingeschakeld (standaard), begint de zoekopdracht aan het begin van het huidige interval zodat het deel kan uitmaken van het resultaat."
|
||||
},
|
||||
"min_segment_duration": {
|
||||
"name": "Minimale segmentduur",
|
||||
"description": "Minimale aaneengesloten looptijd. Voorkomt snel aan-/uitschakelen bij apparaten met minimale looptijden. Automatisch afgerond naar het volgende kwartier. Standaard: 15 minuten. Maximum: 4 uur."
|
||||
},
|
||||
"use_base_unit": {
|
||||
"name": "Basisvaluta gebruiken",
|
||||
"description": "Forceer prijzen in basisvaluta (EUR, NOK) in plaats van de geconfigureerde weergave-eenheid (ct, øre). Handig voor berekeningen."
|
||||
},
|
||||
"search_scope": {
|
||||
"name": "Zoekbereik (snelkoppeling)",
|
||||
"description": "Snelkoppeling voor veelgebruikte zoekbereiken. Overschrijft alle andere tijdopties. today/tomorrow = volledige kalenderdag, remaining_today = nu tot middernacht, next_24h/next_48h = rolling venster vanaf nu."
|
||||
},
|
||||
"max_price_level": {
|
||||
"name": "Maximaal prijsniveau",
|
||||
"description": "Overweeg alleen intervallen op of onder dit Tibber-prijsniveau. very_cheap = meest restrictief, very_expensive = geen beperking."
|
||||
},
|
||||
"min_price_level": {
|
||||
"name": "Minimaal prijsniveau",
|
||||
"description": "Overweeg alleen intervallen op of boven dit Tibber-prijsniveau. Nuttig voor find_most_expensive om te focussen op echt dure intervallen."
|
||||
},
|
||||
"include_comparison_details": {
|
||||
"name": "Vergelijkingsdetails meenemen",
|
||||
"description": "Verrijk het price_comparison-resultaat met extra velden: comparison_price_min, comparison_price_max en (alleen blok) comparison_window_end."
|
||||
},
|
||||
"power_profile": {
|
||||
"name": "Vermogensprofiel",
|
||||
"description": "Variabel vermogensverbruik in watt per 15-minuten-interval. Indien ingesteld, weerspiegelt estimated_total_cost het werkelijke verbruik."
|
||||
}
|
||||
}
|
||||
},
|
||||
"find_most_expensive_hours": {
|
||||
"name": "Duurste uren vinden",
|
||||
"description": "Vindt de duurste intervallen voor een bepaalde totale duur, niet noodzakelijk aaneengesloten. Nuttig voor het identificeren van piekprijsperioden die vermeden moeten worden. Retourneert een schema van intervallen gegroepeerd in aaneengesloten segmenten.",
|
||||
"sections": {
|
||||
"search_range": {
|
||||
"name": "Zoekbereik",
|
||||
"description": "Definieer het tijdvenster voor het zoeken."
|
||||
},
|
||||
"time_alternatives": {
|
||||
"name": "Alternatieve tijdbereiksopties",
|
||||
"description": "Alternatieve manieren om het zoekbereik te definieren via tijdstip en offsets."
|
||||
},
|
||||
"price_filter": {
|
||||
"name": "Prijsniveau-filter",
|
||||
"description": "Beperk het zoeken tot intervallen binnen het opgegeven prijsniveaubereik."
|
||||
},
|
||||
"output": {
|
||||
"name": "Uitvoeropties",
|
||||
"description": "Stuur kostnadsraming en vergelijkingsuitvoer."
|
||||
}
|
||||
},
|
||||
"fields": {
|
||||
"entry_id": {
|
||||
"name": "Item-ID",
|
||||
"description": "De config-item-ID voor de Tibber-integratie."
|
||||
},
|
||||
"duration": {
|
||||
"name": "Duur",
|
||||
"description": "Te vinden dure totale tijd. Automatisch afgerond naar het volgende kwartier. Maximum: 24 uur."
|
||||
},
|
||||
"search_start": {
|
||||
"name": "Zoekstart",
|
||||
"description": "Begin van het zoekbereik als exacte datum en tijd. Hoogste prioriteit — overschrijft alle andere startopties. Standaard is nu als niet opgegeven."
|
||||
},
|
||||
"search_end": {
|
||||
"name": "Zoekeinde",
|
||||
"description": "Einde van het zoekbereik als exacte datum en tijd. Hoogste prioriteit — overschrijft alle andere eindopties. Standaard is einde van morgen als niet opgegeven."
|
||||
},
|
||||
"search_start_time": {
|
||||
"name": "Zoekstart-tijd",
|
||||
"description": "Alternatief: Start zoeken vanaf dit tijdstip. Combineer met dagoffset. Wordt genegeerd als Zoekstart (datum/tijd) is ingesteld."
|
||||
},
|
||||
"search_start_day_offset": {
|
||||
"name": "Zoekstart dagoffset",
|
||||
"description": "Dagoffset voor Zoekstart-tijd. -7 tot 2: -1 = gisteren, 0 = vandaag, 1 = morgen. Negatieve waarden zoeken in het verleden. Wordt alleen gebruikt met Zoekstart-tijd."
|
||||
},
|
||||
"search_end_time": {
|
||||
"name": "Zoekeinde-tijd",
|
||||
"description": "Alternatief: Zoek tot dit tijdstip. Combineer met dagoffset. Wordt genegeerd als Zoekeinde (datum/tijd) is ingesteld."
|
||||
},
|
||||
"search_end_day_offset": {
|
||||
"name": "Zoekeinde dagoffset",
|
||||
"description": "Dagoffset voor Zoekeinde-tijd. -7 tot 2: -1 = gisteren, 0 = vandaag, 1 = morgen. Negatieve waarden zoeken in het verleden. Wordt alleen gebruikt met Zoekeinde-tijd."
|
||||
},
|
||||
"search_start_offset_minutes": {
|
||||
"name": "Zoekstart-offset (minuten)",
|
||||
"description": "Alternatief: Start met zoeken over dit aantal minuten vanaf nu. Positief = toekomst (60 = over 1 uur), negatief = verleden (-60 = 1 uur geleden). Wordt genegeerd als Zoekstart of Zoekstart-tijd is ingesteld."
|
||||
},
|
||||
"search_end_offset_minutes": {
|
||||
"name": "Zoekeinde-offset (minuten)",
|
||||
"description": "Alternatief: Stop met zoeken over dit aantal minuten vanaf nu. Positief = toekomst (480 = over 8 uur), negatief = verleden (-60 = 1 uur geleden). Wordt genegeerd als Zoekeinde of Zoekeinde-tijd is ingesteld."
|
||||
},
|
||||
"include_current_interval": {
|
||||
"name": "Huidig interval opnemen",
|
||||
"description": "Het huidige lopende 15-minuten interval opnemen in de zoekopdracht. Indien ingeschakeld (standaard), begint de zoekopdracht aan het begin van het huidige interval zodat het deel kan uitmaken van het resultaat."
|
||||
},
|
||||
"min_segment_duration": {
|
||||
"name": "Minimale segmentduur",
|
||||
"description": "Minimale aaneengesloten looptijd. Voorkomt snel aan-/uitschakelen bij apparaten met minimale looptijden. Automatisch afgerond naar het volgende kwartier. Standaard: 15 minuten. Maximum: 4 uur."
|
||||
},
|
||||
"use_base_unit": {
|
||||
"name": "Basisvaluta gebruiken",
|
||||
"description": "Forceer prijzen in basisvaluta (EUR, NOK) in plaats van de geconfigureerde weergave-eenheid (ct, øre). Handig voor berekeningen."
|
||||
},
|
||||
"search_scope": {
|
||||
"name": "Zoekbereik (snelkoppeling)",
|
||||
"description": "Snelkoppeling voor veelgebruikte zoekbereiken. Overschrijft alle andere tijdopties. today/tomorrow = volledige kalenderdag, remaining_today = nu tot middernacht, next_24h/next_48h = rolling venster vanaf nu."
|
||||
},
|
||||
"max_price_level": {
|
||||
"name": "Maximaal prijsniveau",
|
||||
"description": "Overweeg alleen intervallen op of onder dit Tibber-prijsniveau. very_cheap = meest restrictief, very_expensive = geen beperking."
|
||||
},
|
||||
"min_price_level": {
|
||||
"name": "Minimaal prijsniveau",
|
||||
"description": "Overweeg alleen intervallen op of boven dit Tibber-prijsniveau. Nuttig voor find_most_expensive om te focussen op echt dure intervallen."
|
||||
},
|
||||
"include_comparison_details": {
|
||||
"name": "Vergelijkingsdetails meenemen",
|
||||
"description": "Verrijk het price_comparison-resultaat met extra velden: comparison_price_min, comparison_price_max en (alleen blok) comparison_window_end."
|
||||
},
|
||||
"power_profile": {
|
||||
"name": "Vermogensprofiel",
|
||||
"description": "Variabel vermogensverbruik in watt per 15-minuten-interval. Indien ingesteld, weerspiegelt estimated_total_cost het werkelijke verbruik."
|
||||
}
|
||||
}
|
||||
},
|
||||
"find_cheapest_schedule": {
|
||||
"name": "Goedkoopste schema vinden",
|
||||
"description": "Plant meerdere apparaten optimaal zonder tijdoverlap. Elke taak krijgt het goedkoopste beschikbare aaneengesloten tijdvenster.",
|
||||
"sections": {
|
||||
"scheduling_options": {
|
||||
"name": "Planningsopties",
|
||||
"description": "Configureer taken en tussenpozen."
|
||||
},
|
||||
"search_range": {
|
||||
"name": "Zoekbereik",
|
||||
"description": "Definieer het tijdvenster voor het zoeken."
|
||||
},
|
||||
"time_alternatives": {
|
||||
"name": "Alternatieve tijdbereiksopties",
|
||||
"description": "Alternatieve manieren om het zoekbereik te definieren via tijdstip en offsets."
|
||||
},
|
||||
"price_filter": {
|
||||
"name": "Prijsniveau-filter",
|
||||
"description": "Beperk het zoeken tot intervallen binnen het opgegeven prijsniveaubereik."
|
||||
},
|
||||
"output": {
|
||||
"name": "Uitvoeropties",
|
||||
"description": "Stuur kostnadsraming en vergelijkingsuitvoer."
|
||||
}
|
||||
},
|
||||
"fields": {
|
||||
"entry_id": {
|
||||
"name": "Item-ID",
|
||||
"description": "De config-item-ID voor de Tibber-integratie."
|
||||
},
|
||||
"tasks": {
|
||||
"name": "Taken",
|
||||
"description": "Lijst van in te plannen taken. Elke taak heeft name (tekst) en duration (hh:mm:ss). Optioneel power_profile (watt per 15-min-interval). Maximaal 4 taken."
|
||||
},
|
||||
"gap_minutes": {
|
||||
"name": "Tussenpose tussen taken (minuten)",
|
||||
"description": "Minimale tussenpose in minuten tussen opeenvolgende ingeplande taken. Afgerond omhoog tot 15 minuten. Standaard: 0 (geen tussenpose)."
|
||||
},
|
||||
"search_scope": {
|
||||
"name": "Zoekbereik (snelkoppeling)",
|
||||
"description": "Snelkoppeling voor veelgebruikte zoekbereiken. Overschrijft alle andere tijdopties. today/tomorrow = volledige kalenderdag, remaining_today = nu tot middernacht, next_24h/next_48h = rolling venster vanaf nu."
|
||||
},
|
||||
"search_start": {
|
||||
"name": "Zoekstart",
|
||||
"description": "Begin van het zoekbereik als exacte datum en tijd. Hoogste prioriteit — overschrijft alle andere startopties. Standaard is nu als niet opgegeven."
|
||||
},
|
||||
"search_end": {
|
||||
"name": "Zoekeinde",
|
||||
"description": "Einde van het zoekbereik als exacte datum en tijd. Hoogste prioriteit — overschrijft alle andere eindopties. Standaard is einde van morgen als niet opgegeven."
|
||||
},
|
||||
"search_start_time": {
|
||||
"name": "Zoekstart-tijd",
|
||||
"description": "Alternatief: Start zoeken vanaf dit tijdstip. Combineer met dagoffset. Wordt genegeerd als Zoekstart (datum/tijd) is ingesteld."
|
||||
},
|
||||
"search_start_day_offset": {
|
||||
"name": "Zoekstart dagoffset",
|
||||
"description": "Dagoffset voor Zoekstart-tijd. -7 tot 2: -1 = gisteren, 0 = vandaag, 1 = morgen. Negatieve waarden zoeken in het verleden. Wordt alleen gebruikt met Zoekstart-tijd."
|
||||
},
|
||||
"search_end_time": {
|
||||
"name": "Zoekeinde-tijd",
|
||||
"description": "Alternatief: Zoek tot dit tijdstip. Combineer met dagoffset. Wordt genegeerd als Zoekeinde (datum/tijd) is ingesteld."
|
||||
},
|
||||
"search_end_day_offset": {
|
||||
"name": "Zoekeinde dagoffset",
|
||||
"description": "Dagoffset voor Zoekeinde-tijd. -7 tot 2: -1 = gisteren, 0 = vandaag, 1 = morgen. Negatieve waarden zoeken in het verleden. Wordt alleen gebruikt met Zoekeinde-tijd."
|
||||
},
|
||||
"search_start_offset_minutes": {
|
||||
"name": "Zoekstart-offset (minuten)",
|
||||
"description": "Alternatief: Start met zoeken over dit aantal minuten vanaf nu. Positief = toekomst (60 = over 1 uur), negatief = verleden (-60 = 1 uur geleden). Wordt genegeerd als Zoekstart of Zoekstart-tijd is ingesteld."
|
||||
},
|
||||
"search_end_offset_minutes": {
|
||||
"name": "Zoekeinde-offset (minuten)",
|
||||
"description": "Alternatief: Stop met zoeken over dit aantal minuten vanaf nu. Positief = toekomst (480 = over 8 uur), negatief = verleden (-60 = 1 uur geleden). Wordt genegeerd als Zoekeinde of Zoekeinde-tijd is ingesteld."
|
||||
},
|
||||
"include_current_interval": {
|
||||
"name": "Huidig interval opnemen",
|
||||
"description": "Het huidige lopende 15-minuten interval opnemen in de zoekopdracht. Indien ingeschakeld (standaard), begint de zoekopdracht aan het begin van het huidige interval zodat het deel kan uitmaken van het resultaat."
|
||||
},
|
||||
"max_price_level": {
|
||||
"name": "Maximaal prijsniveau",
|
||||
"description": "Overweeg alleen intervallen op of onder dit Tibber-prijsniveau. very_cheap = meest restrictief, very_expensive = geen beperking."
|
||||
},
|
||||
"min_price_level": {
|
||||
"name": "Minimaal prijsniveau",
|
||||
"description": "Overweeg alleen intervallen op of boven dit Tibber-prijsniveau. Nuttig voor find_most_expensive om te focussen op echt dure intervallen."
|
||||
},
|
||||
"use_base_unit": {
|
||||
"name": "Basisvaluta gebruiken",
|
||||
"description": "Forceer prijzen in basisvaluta (EUR, NOK) in plaats van de geconfigureerde weergave-eenheid (ct, øre). Handig voor berekeningen."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"selector": {
|
||||
|
|
@ -1361,6 +1944,15 @@
|
|||
"median": "Mediaan",
|
||||
"mean": "Rekenkundig Gemiddelde"
|
||||
}
|
||||
},
|
||||
"search_scope": {
|
||||
"options": {
|
||||
"today": "Vandaag",
|
||||
"tomorrow": "Morgen",
|
||||
"remaining_today": "Rest van vandaag",
|
||||
"next_24h": "Komende 24 uur",
|
||||
"next_48h": "Komende 48 uur"
|
||||
}
|
||||
}
|
||||
},
|
||||
"title": "Tibber Prijsinformatie & Beoordelingen"
|
||||
|
|
|
|||
|
|
@ -59,15 +59,7 @@
|
|||
"home_already_configured": "Detta hem är redan konfigurerat i en annan post. Varje hem kan bara konfigureras en gång.",
|
||||
"no_active_subscription": "Detta hem har inget aktivt Tibber-avtal. Endast hem med aktiva elavtal kan läggas till i Home Assistant.",
|
||||
"subscription_expired": "Tibber-avtalet för detta hem har löpt ut. Endast hem med aktiva eller framtida elavtal kan läggas till i Home Assistant.",
|
||||
"future_subscription_warning": "Obs: Detta hems Tibber-avtal har inte börjat ännu. Funktionaliteten kan vara begränsad tills avtalet blir aktivt.",
|
||||
"invalid_yaml_syntax": "Ogiltig YAML-syntax. Kontrollera indragning, kolon och specialtecken.",
|
||||
"invalid_yaml_structure": "YAML måste vara en ordbok/objekt (nyckel: värde-par), inte en lista eller vanlig text.",
|
||||
"service_call_failed": "Tjänsteanrop validering misslyckades: {error_detail}",
|
||||
"missing_entry_id": "Post-ID krävs men angavs inte.",
|
||||
"invalid_entry_id": "Ogiltigt post-ID eller post hittades inte.",
|
||||
"missing_home_id": "Hem-ID saknas från konfigurationsposten.",
|
||||
"user_data_not_available": "Användardata är inte tillgänglig. Uppdatera användardata först.",
|
||||
"price_fetch_failed": "Misslyckades med att hämta prisdata. Kontrollera loggarna för detaljer."
|
||||
"future_subscription_warning": "Obs: Detta hems Tibber-avtal har inte börjat ännu. Funktionaliteten kan vara begränsad tills avtalet blir aktivt."
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "Alla tillgängliga Tibber-hem är redan konfigurerade. Varje hem kan bara konfigureras en gång.",
|
||||
|
|
@ -246,6 +238,20 @@
|
|||
"min_periods_best": "Minsta antal bästa prisperioder att sikta på per dag. Filter kommer att relaxeras steg för steg för att försöka uppnå detta antal. Endast aktiv när 'Uppnå Minimiantal' är aktiverad. Standard: 1",
|
||||
"relaxation_attempts_best": "Hur många flexnivåer (försök) att prova innan man ger upp. Varje försök kör alla filterkombinationer på den nya flexnivån. Fler försök ökar chansen att hitta ytterligare perioder på bekostnad av längre behandlingstid."
|
||||
}
|
||||
},
|
||||
"extension_settings": {
|
||||
"name": "Utvidgning av periodändarna",
|
||||
"description": "Utvidga eventuellt hittade bästa-prisperioder vid båda ändarna för att inkludera angränsande mycket billiga intervall.",
|
||||
"data": {
|
||||
"best_price_extend_to_very_cheap": "Utvidga till mycket billiga intervall",
|
||||
"best_price_max_extension_intervals": "Maximalt antal utvidgningsintervall",
|
||||
"best_price_geometric_flex": "Geometrisk flexbonus"
|
||||
},
|
||||
"data_description": {
|
||||
"best_price_extend_to_very_cheap": "När aktiverat utvidgas hittade bästa-prisperioder utåt för att inkludera angränsande intervall med prisnivån 'Mycket billig'. Detta fångar upp extremt billiga intervall vid kanterna av hittade perioder.",
|
||||
"best_price_max_extension_intervals": "Maximalt antal extra intervall per sida (vänster och höger kant). Varje intervall är 15 minuter. Exempel: 4 intervall = upp till 1 timmes utvidgning per kant. Standard: 4",
|
||||
"best_price_geometric_flex": "Extra flexprocentandel för intervall som faller inom en detekterad prisdal (V-form). När ett dalmönster detekteras för dagen får intervall inom dalzonen denna extra tolerans, vilket gör att perioddektorn är mer benägen att inkludera dem. 0 = inaktiverad. Standard: 0"
|
||||
}
|
||||
}
|
||||
},
|
||||
"submit": "↩ Spara & tillbaka"
|
||||
|
|
@ -293,6 +299,20 @@
|
|||
"min_periods_peak": "Minsta antal topprisperioder att sikta på per dag. Filter kommer att relaxeras steg för steg för att försöka uppnå detta antal. Endast aktiv när 'Uppnå Minimiantal' är aktiverad. Standard: 1",
|
||||
"relaxation_attempts_peak": "Hur många flexnivåer (försök) att prova innan man ger upp. Varje försök kör alla filterkombinationer på den nya flexnivån. Fler försök ökar chansen att hitta ytterligare toppperioder på bekostnad av längre behandlingstid."
|
||||
}
|
||||
},
|
||||
"extension_settings": {
|
||||
"name": "Utvidgning av periodändarna",
|
||||
"description": "Utvidga eventuellt hittade topprisperioder vid båda ändarna för att inkludera angränsande mycket dyra intervall.",
|
||||
"data": {
|
||||
"peak_price_extend_to_very_expensive": "Utvidga till mycket dyra intervall",
|
||||
"peak_price_max_extension_intervals": "Maximalt antal utvidgningsintervall",
|
||||
"peak_price_geometric_flex": "Geometrisk flexbonus"
|
||||
},
|
||||
"data_description": {
|
||||
"peak_price_extend_to_very_expensive": "När aktiverat utvidgas hittade topprisperioder utåt för att inkludera angränsande intervall med prisnivån 'Mycket dyr'. Detta fångar upp extremt dyra intervall vid kanterna av hittade perioder.",
|
||||
"peak_price_max_extension_intervals": "Maximalt antal extra intervall per sida (vänster och höger kant). Varje intervall är 15 minuter. Exempel: 4 intervall = upp till 1 timmes utvidgning per kant. Standard: 4",
|
||||
"peak_price_geometric_flex": "Extra flexprocentandel för intervall som faller inom en detekterad prispeak (Λ-form). När ett peak-mönster detekteras för dagen får intervall inom peakzonen denna extra tolerans, vilket gör att perioddetektor är mer benägen att inkludera dem. 0 = inaktiverad. Standard: 0"
|
||||
}
|
||||
}
|
||||
},
|
||||
"submit": "↩ Spara & tillbaka"
|
||||
|
|
@ -945,6 +965,45 @@
|
|||
"unknown": "Okänd"
|
||||
}
|
||||
},
|
||||
"day_pattern_yesterday": {
|
||||
"name": "Prismönster Igår",
|
||||
"state": {
|
||||
"valley": "Dal",
|
||||
"peak": "Topp",
|
||||
"double_valley": "Dubbeldal",
|
||||
"double_peak": "Dubbeltopp",
|
||||
"flat": "Flat",
|
||||
"rising": "Stigande",
|
||||
"falling": "Fallande",
|
||||
"mixed": "Blandad"
|
||||
}
|
||||
},
|
||||
"day_pattern_today": {
|
||||
"name": "Prismönster Idag",
|
||||
"state": {
|
||||
"valley": "Dal",
|
||||
"peak": "Topp",
|
||||
"double_valley": "Dubbeldal",
|
||||
"double_peak": "Dubbeltopp",
|
||||
"flat": "Flat",
|
||||
"rising": "Stigande",
|
||||
"falling": "Fallande",
|
||||
"mixed": "Blandad"
|
||||
}
|
||||
},
|
||||
"day_pattern_tomorrow": {
|
||||
"name": "Prismönster Imorgon",
|
||||
"state": {
|
||||
"valley": "Dal",
|
||||
"peak": "Topp",
|
||||
"double_valley": "Dubbeldal",
|
||||
"double_peak": "Dubbeltopp",
|
||||
"flat": "Flat",
|
||||
"rising": "Stigande",
|
||||
"falling": "Fallande",
|
||||
"mixed": "Blandad"
|
||||
}
|
||||
},
|
||||
"chart_data_export": {
|
||||
"name": "Diagramdataexport",
|
||||
"state": {
|
||||
|
|
@ -1055,6 +1114,62 @@
|
|||
"description": "Denna uppdatering innehåller ändringar som tillämpades automatiskt.\n\n**Omdöpta entiteter ({count})**\n\nFöljande entity-nycklar döptes om automatiskt. Dina befintliga entity-ID:n och automatiseringar förblir intakta:\n\n{entity_list}\n\n**Ändrade varaktighetssensorvärden**\n\nAlla varaktighetssensorer (återstående tid, startar om, periodvaraktighet, trendändrings-nedräkning) rapporterar nu sitt tillståndsvärde i **minuter** istället för timmar. Visningsenheten i dashboards förblir timmar som standard.\n\nOm du har automatiseringar med numeriska jämförelser på dessa sensorer, uppdatera dina tröskelvärden:\n- Gammalt: `state < 0.25` (15 minuter som timmar)\n- Nytt: `state < 15` (15 minuter)\n\nStäng detta meddelande efter att du har granskat dina automatiseringar."
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"no_entries_found": {
|
||||
"message": "Inga Tibber Prices integrationsposter hittades. Konfigurera integrationen först."
|
||||
},
|
||||
"multiple_entries_no_entry_id": {
|
||||
"message": "Flera Tibber Prices poster hittades. Ange 'entry_id' för att välja vilken post som ska användas."
|
||||
},
|
||||
"invalid_entry_id": {
|
||||
"message": "Ogiltig eller otillgänglig konfigurationspost. Kontrollera post-ID:t och se till att integrationen är laddad."
|
||||
},
|
||||
"missing_home_id": {
|
||||
"message": "Hem-ID hittades inte i konfigurationsposten. Konfigurera om integrationen."
|
||||
},
|
||||
"user_data_not_available": {
|
||||
"message": "Användardata är inte tillgänglig ännu. Vänta tills den första datauppdateringen är klar."
|
||||
},
|
||||
"timezone_not_found": {
|
||||
"message": "Kunde inte fastställa hemmets tidszon. Kontrollera hemkonfigurationen i ditt Tibber-konto."
|
||||
},
|
||||
"end_before_start": {
|
||||
"message": "Sluttid måste vara efter starttid."
|
||||
},
|
||||
"price_fetch_failed": {
|
||||
"message": "Kunde inte hämta prisdata från Tibber API. Försök igen senare."
|
||||
},
|
||||
"invalid_search_scope": {
|
||||
"message": "Ogiltigt sökområde. Giltiga värden är: today, tomorrow, remaining_today, next_24h, next_48h."
|
||||
},
|
||||
"scope_conflicts_with_range": {
|
||||
"message": "search_scope kan inte kombineras med explicita områdesparametrar: {params}. Använd antingen search_scope ELLER explicita start-/slutparametrar."
|
||||
},
|
||||
"day_offset_requires_time": {
|
||||
"message": "{offset_param} kräver att {time_param} är satt. Dagsförskjutning ändrar bara datumet för en explicit tidsparameter."
|
||||
},
|
||||
"min_level_exceeds_max": {
|
||||
"message": "min_price_level '{min_level}' är högre än max_price_level '{max_level}'. Miniminivån måste vara lika med eller lägre än maximinivån."
|
||||
},
|
||||
"power_profile_length_mismatch": {
|
||||
"message": "power_profile har {profile_length} poster men varaktigheten kräver {interval_count} intervaller ({duration_minutes} minuter). power_profile måste ha exakt en post per 15-minutersintervall."
|
||||
},
|
||||
"level_and_rating_filter_conflict": {
|
||||
"message": "level_filter och rating_level_filter kan inte användas tillsammans. Använd bara en filtertyp per begäran."
|
||||
},
|
||||
"insert_nulls_requires_filter": {
|
||||
"message": "insert_nulls-läge '{mode}' kräver ett level_filter eller rating_level_filter för att definiera segment. Utan filter, använd insert_nulls: none."
|
||||
},
|
||||
"connect_segments_requires_segments_mode": {
|
||||
"message": "connect_segments kräver att insert_nulls är satt till 'segments'. Ställ in insert_nulls: segments för att använda segmentanslutning."
|
||||
},
|
||||
"array_fields_requires_array_format": {
|
||||
"message": "array_fields kan bara användas med output_format: array_of_arrays. Ändra utdataformatet eller ta bort array_fields."
|
||||
},
|
||||
"invalid_array_fields": {
|
||||
"message": "Ogiltig array_fields-mall. Använd fältnamn inom klammerparenteser, t.ex. '{start_time}, {price_per_kwh}, {level}'."
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"get_price": {
|
||||
"name": "Hämta prisdata",
|
||||
|
|
@ -1261,6 +1376,474 @@
|
|||
"description": "Config entry-ID för Tibber-integrationen."
|
||||
}
|
||||
}
|
||||
},
|
||||
"find_cheapest_block": {
|
||||
"name": "Hitta billigaste blocket",
|
||||
"description": "Hittar det billigaste sammanhängande tidsfönstret med en given varaktighet. Designat för apparatschemaläggning: diskmaskin, tvättmaskin, torktumlare osv. Returnerar det billigaste fönstret med start-/sluttider och prisstatistik.",
|
||||
"sections": {
|
||||
"search_range": {
|
||||
"name": "Soekomraade",
|
||||
"description": "Definiera tidsfoenstret att soeka inom."
|
||||
},
|
||||
"time_alternatives": {
|
||||
"name": "Alternativa tidsinstaellningar",
|
||||
"description": "Alternativa saett att definiera soekomraadet via tidpunkt och offset."
|
||||
},
|
||||
"price_filter": {
|
||||
"name": "Prisnivaaefilter",
|
||||
"description": "Begraensa soekningen till intervall inom det angivna prisnivaaeintervallet."
|
||||
},
|
||||
"output": {
|
||||
"name": "Utdataalternativ",
|
||||
"description": "Styr kostnadsuppskattning och jaemfoerelseresultat."
|
||||
}
|
||||
},
|
||||
"fields": {
|
||||
"entry_id": {
|
||||
"name": "Entry-ID",
|
||||
"description": "Config entry-ID för Tibber-integrationen."
|
||||
},
|
||||
"duration": {
|
||||
"name": "Varaktighet",
|
||||
"description": "Längd på det önskade sammanhängande fönstret. Avrundas automatiskt uppåt till närmaste kvart. Maximum: 12 timmar."
|
||||
},
|
||||
"search_start": {
|
||||
"name": "Sökstart",
|
||||
"description": "Start av sökintervallet som exakt datum och tid. Högsta prioritet — åsidosätter alla andra startalternativ. Standard är nu om inte angivet."
|
||||
},
|
||||
"search_end": {
|
||||
"name": "Sökslut",
|
||||
"description": "Slut av sökintervallet som exakt datum och tid. Högsta prioritet — åsidosätter alla andra slutalternativ. Standard är slutet av imorgon om inte angivet."
|
||||
},
|
||||
"search_start_time": {
|
||||
"name": "Sökstart-klockslag",
|
||||
"description": "Alternativ: Börja söka från detta klockslag. Kombinera med dagförskjutning. Ignoreras om Sökstart (datum/tid) är satt."
|
||||
},
|
||||
"search_start_day_offset": {
|
||||
"name": "Sökstart dagförskjutning",
|
||||
"description": "Dagförskjutning för Sökstart-klockslag. -7 till 2: -1 = igår, 0 = idag, 1 = imorgon. Negativa värden söker i det förflutna. Används bara med Sökstart-klockslag."
|
||||
},
|
||||
"search_end_time": {
|
||||
"name": "Sökslut-klockslag",
|
||||
"description": "Alternativ: Sök till detta klockslag. Kombinera med dagförskjutning. Ignoreras om Sökslut (datum/tid) är satt."
|
||||
},
|
||||
"search_end_day_offset": {
|
||||
"name": "Sökslut dagförskjutning",
|
||||
"description": "Dagförskjutning för Sökslut-klockslag. -7 till 2: -1 = igår, 0 = idag, 1 = imorgon. Negativa värden söker i det förflutna. Används bara med Sökslut-klockslag."
|
||||
},
|
||||
"search_start_offset_minutes": {
|
||||
"name": "Sökstart-förskjutning (minuter)",
|
||||
"description": "Alternativ: Börja söka detta antal minuter från nu. Positivt = framtid (60 = om 1 timme), negativt = förflutet (-60 = 1 timme sedan). Ignoreras om Sökstart eller Sökstart-klockslag är satt."
|
||||
},
|
||||
"search_end_offset_minutes": {
|
||||
"name": "Sökslut-förskjutning (minuter)",
|
||||
"description": "Alternativ: Sluta söka detta antal minuter från nu. Positivt = framtid (480 = om 8 timmar), negativt = förflutet (-60 = 1 timme sedan). Ignoreras om Sökslut eller Sökslut-klockslag är satt."
|
||||
},
|
||||
"include_current_interval": {
|
||||
"name": "Inkludera aktuellt intervall",
|
||||
"description": "Inkludera det pågående 15-minutersintervallet i sökningen. När aktiverat (standard), börjar sökningen vid början av det aktuella intervallet så att det kan vara en del av resultatet."
|
||||
},
|
||||
"use_base_unit": {
|
||||
"name": "Använd basvaluta",
|
||||
"description": "Tvinga priser i basvaluta (EUR, NOK) istället för konfigurerad visningsenhet (ct, öre). Användbart för beräkningar."
|
||||
},
|
||||
"search_scope": {
|
||||
"name": "Soekumfaang (genvaeg)",
|
||||
"description": "Genvaeg foer vanliga soekomraaden. Aasidosaetter alla andra tidsalternativ. today/tomorrow = hela kalenderdagen, remaining_today = nu till midnatt, next_24h/next_48h = rullande foenster fraen nu."
|
||||
},
|
||||
"max_price_level": {
|
||||
"name": "Maximal prisnivaae",
|
||||
"description": "Ta bara med intervall paa eller under denna Tibber-prisnivaae. very_cheap = mest restriktivt, very_expensive = ingen begraensning."
|
||||
},
|
||||
"min_price_level": {
|
||||
"name": "Minimal prisnivaae",
|
||||
"description": "Ta bara med intervall paa eller oever denna Tibber-prisnivaae. Anvaendbart foer find_most_expensive foer att fokusera paa verkligt dyra intervall."
|
||||
},
|
||||
"include_comparison_details": {
|
||||
"name": "Inkludera jaemfoerelsdetaljer",
|
||||
"description": "Berika price_comparison-resultatet med ytterligare faelt: comparison_price_min, comparison_price_max och (endast block) comparison_window_end."
|
||||
},
|
||||
"power_profile": {
|
||||
"name": "Effektprofil",
|
||||
"description": "Variabel effektfoerbruekning i watt per 15-minutersintervall. Om instaellt, aaterspeglar estimated_total_cost faktisk foerbruekning istaellet foer en fast 1 kW-last."
|
||||
}
|
||||
}
|
||||
},
|
||||
"find_most_expensive_block": {
|
||||
"name": "Hitta dyraste blocket",
|
||||
"description": "Hittar det dyraste sammanhängande tidsfönstret med en given varaktighet. Användbart för att identifiera topprisperioder som bör undvikas. Returnerar det dyraste fönstret med start-/sluttider och prisstatistik.",
|
||||
"sections": {
|
||||
"search_range": {
|
||||
"name": "Soekomraade",
|
||||
"description": "Definiera tidsfoenstret att soeka inom."
|
||||
},
|
||||
"time_alternatives": {
|
||||
"name": "Alternativa tidsinstaellningar",
|
||||
"description": "Alternativa saett att definiera soekomraadet via tidpunkt och offset."
|
||||
},
|
||||
"price_filter": {
|
||||
"name": "Prisnivaaefilter",
|
||||
"description": "Begraensa soekningen till intervall inom det angivna prisnivaaeintervallet."
|
||||
},
|
||||
"output": {
|
||||
"name": "Utdataalternativ",
|
||||
"description": "Styr kostnadsuppskattning och jaemfoerelseresultat."
|
||||
}
|
||||
},
|
||||
"fields": {
|
||||
"entry_id": {
|
||||
"name": "Entry-ID",
|
||||
"description": "Config entry-ID för Tibber-integrationen."
|
||||
},
|
||||
"duration": {
|
||||
"name": "Varaktighet",
|
||||
"description": "Längd på det önskade sammanhängande fönstret. Avrundas automatiskt uppåt till närmaste kvart. Maximum: 12 timmar."
|
||||
},
|
||||
"search_start": {
|
||||
"name": "Sökstart",
|
||||
"description": "Start av sökintervallet som exakt datum och tid. Högsta prioritet — åsidosätter alla andra startalternativ. Standard är nu om inte angivet."
|
||||
},
|
||||
"search_end": {
|
||||
"name": "Sökslut",
|
||||
"description": "Slut av sökintervallet som exakt datum och tid. Högsta prioritet — åsidosätter alla andra slutalternativ. Standard är slutet av imorgon om inte angivet."
|
||||
},
|
||||
"search_start_time": {
|
||||
"name": "Sökstart-klockslag",
|
||||
"description": "Alternativ: Börja söka från detta klockslag. Kombinera med dagförskjutning. Ignoreras om Sökstart (datum/tid) är satt."
|
||||
},
|
||||
"search_start_day_offset": {
|
||||
"name": "Sökstart dagförskjutning",
|
||||
"description": "Dagförskjutning för Sökstart-klockslag. -7 till 2: -1 = igår, 0 = idag, 1 = imorgon. Negativa värden söker i det förflutna. Används bara med Sökstart-klockslag."
|
||||
},
|
||||
"search_end_time": {
|
||||
"name": "Sökslut-klockslag",
|
||||
"description": "Alternativ: Sök till detta klockslag. Kombinera med dagförskjutning. Ignoreras om Sökslut (datum/tid) är satt."
|
||||
},
|
||||
"search_end_day_offset": {
|
||||
"name": "Sökslut dagförskjutning",
|
||||
"description": "Dagförskjutning för Sökslut-klockslag. -7 till 2: -1 = igår, 0 = idag, 1 = imorgon. Negativa värden söker i det förflutna. Används bara med Sökslut-klockslag."
|
||||
},
|
||||
"search_start_offset_minutes": {
|
||||
"name": "Sökstart-förskjutning (minuter)",
|
||||
"description": "Alternativ: Börja söka detta antal minuter från nu. Positivt = framtid (60 = om 1 timme), negativt = förflutet (-60 = 1 timme sedan). Ignoreras om Sökstart eller Sökstart-klockslag är satt."
|
||||
},
|
||||
"search_end_offset_minutes": {
|
||||
"name": "Sökslut-förskjutning (minuter)",
|
||||
"description": "Alternativ: Sluta söka detta antal minuter från nu. Positivt = framtid (480 = om 8 timmar), negativt = förflutet (-60 = 1 timme sedan). Ignoreras om Sökslut eller Sökslut-klockslag är satt."
|
||||
},
|
||||
"include_current_interval": {
|
||||
"name": "Inkludera aktuellt intervall",
|
||||
"description": "Inkludera det pågående 15-minutersintervallet i sökningen. När aktiverat (standard), börjar sökningen vid början av det aktuella intervallet så att det kan vara en del av resultatet."
|
||||
},
|
||||
"use_base_unit": {
|
||||
"name": "Använd basvaluta",
|
||||
"description": "Tvinga priser i basvaluta (EUR, NOK) istället för konfigurerad visningsenhet (ct, öre). Användbart för beräkningar."
|
||||
},
|
||||
"search_scope": {
|
||||
"name": "Soekumfaang (genvaeg)",
|
||||
"description": "Genvaeg foer vanliga soekomraaden. Aasidosaetter alla andra tidsalternativ. today/tomorrow = hela kalenderdagen, remaining_today = nu till midnatt, next_24h/next_48h = rullande foenster fraen nu."
|
||||
},
|
||||
"max_price_level": {
|
||||
"name": "Maximal prisnivaae",
|
||||
"description": "Ta bara med intervall paa eller under denna Tibber-prisnivaae. very_cheap = mest restriktivt, very_expensive = ingen begraensning."
|
||||
},
|
||||
"min_price_level": {
|
||||
"name": "Minimal prisnivaae",
|
||||
"description": "Ta bara med intervall paa eller oever denna Tibber-prisnivaae. Anvaendbart foer find_most_expensive foer att fokusera paa verkligt dyra intervall."
|
||||
},
|
||||
"include_comparison_details": {
|
||||
"name": "Inkludera jaemfoerelsdetaljer",
|
||||
"description": "Berika price_comparison-resultatet med ytterligare faelt: comparison_price_min, comparison_price_max och (endast block) comparison_window_end."
|
||||
},
|
||||
"power_profile": {
|
||||
"name": "Effektprofil",
|
||||
"description": "Variabel effektfoerbruekning i watt per 15-minutersintervall. Om instaellt, aaterspeglar estimated_total_cost faktisk foerbruekning istaellet foer en fast 1 kW-last."
|
||||
}
|
||||
}
|
||||
},
|
||||
"find_cheapest_hours": {
|
||||
"name": "Hitta billigaste timmarna",
|
||||
"description": "Hittar de billigaste intervallen för en given total varaktighet, inte nödvändigtvis sammanhängande. Designat för flexibla laster: batteriladdning, elbil, varmvattenberedare. Returnerar ett schema av intervaller grupperade i sammanhängande segment.",
|
||||
"sections": {
|
||||
"search_range": {
|
||||
"name": "Soekomraade",
|
||||
"description": "Definiera tidsfoenstret att soeka inom."
|
||||
},
|
||||
"time_alternatives": {
|
||||
"name": "Alternativa tidsinstaellningar",
|
||||
"description": "Alternativa saett att definiera soekomraadet via tidpunkt och offset."
|
||||
},
|
||||
"price_filter": {
|
||||
"name": "Prisnivaaefilter",
|
||||
"description": "Begraensa soekningen till intervall inom det angivna prisnivaaeintervallet."
|
||||
},
|
||||
"output": {
|
||||
"name": "Utdataalternativ",
|
||||
"description": "Styr kostnadsuppskattning och jaemfoerelseresultat."
|
||||
}
|
||||
},
|
||||
"fields": {
|
||||
"entry_id": {
|
||||
"name": "Entry-ID",
|
||||
"description": "Config entry-ID för Tibber-integrationen."
|
||||
},
|
||||
"duration": {
|
||||
"name": "Varaktighet",
|
||||
"description": "Behövd billig total tid. Avrundas automatiskt uppåt till närmaste kvart. Maximum: 24 timmar."
|
||||
},
|
||||
"search_start": {
|
||||
"name": "Sökstart",
|
||||
"description": "Start av sökintervallet som exakt datum och tid. Högsta prioritet — åsidosätter alla andra startalternativ. Standard är nu om inte angivet."
|
||||
},
|
||||
"search_end": {
|
||||
"name": "Sökslut",
|
||||
"description": "Slut av sökintervallet som exakt datum och tid. Högsta prioritet — åsidosätter alla andra slutalternativ. Standard är slutet av imorgon om inte angivet."
|
||||
},
|
||||
"search_start_time": {
|
||||
"name": "Sökstart-klockslag",
|
||||
"description": "Alternativ: Börja söka från detta klockslag. Kombinera med dagförskjutning. Ignoreras om Sökstart (datum/tid) är satt."
|
||||
},
|
||||
"search_start_day_offset": {
|
||||
"name": "Sökstart dagförskjutning",
|
||||
"description": "Dagförskjutning för Sökstart-klockslag. -7 till 2: -1 = igår, 0 = idag, 1 = imorgon. Negativa värden söker i det förflutna. Används bara med Sökstart-klockslag."
|
||||
},
|
||||
"search_end_time": {
|
||||
"name": "Sökslut-klockslag",
|
||||
"description": "Alternativ: Sök till detta klockslag. Kombinera med dagförskjutning. Ignoreras om Sökslut (datum/tid) är satt."
|
||||
},
|
||||
"search_end_day_offset": {
|
||||
"name": "Sökslut dagförskjutning",
|
||||
"description": "Dagförskjutning för Sökslut-klockslag. -7 till 2: -1 = igår, 0 = idag, 1 = imorgon. Negativa värden söker i det förflutna. Används bara med Sökslut-klockslag."
|
||||
},
|
||||
"search_start_offset_minutes": {
|
||||
"name": "Sökstart-förskjutning (minuter)",
|
||||
"description": "Alternativ: Börja söka detta antal minuter från nu. Positivt = framtid (60 = om 1 timme), negativt = förflutet (-60 = 1 timme sedan). Ignoreras om Sökstart eller Sökstart-klockslag är satt."
|
||||
},
|
||||
"search_end_offset_minutes": {
|
||||
"name": "Sökslut-förskjutning (minuter)",
|
||||
"description": "Alternativ: Sluta söka detta antal minuter från nu. Positivt = framtid (480 = om 8 timmar), negativt = förflutet (-60 = 1 timme sedan). Ignoreras om Sökslut eller Sökslut-klockslag är satt."
|
||||
},
|
||||
"include_current_interval": {
|
||||
"name": "Inkludera aktuellt intervall",
|
||||
"description": "Inkludera det pågående 15-minutersintervallet i sökningen. När aktiverat (standard), börjar sökningen vid början av det aktuella intervallet så att det kan vara en del av resultatet."
|
||||
},
|
||||
"min_segment_duration": {
|
||||
"name": "Minsta segmentvaraktighet",
|
||||
"description": "Minsta sammanhängande körtid. Förhindrar snabb av/på-cykling för enheter med minsta körtider. Avrundas automatiskt uppåt till närmaste kvart. Standard: 15 minuter. Maximum: 4 timmar."
|
||||
},
|
||||
"use_base_unit": {
|
||||
"name": "Använd basvaluta",
|
||||
"description": "Tvinga priser i basvaluta (EUR, NOK) istället för konfigurerad visningsenhet (ct, öre). Användbart för beräkningar."
|
||||
},
|
||||
"search_scope": {
|
||||
"name": "Soekumfaang (genvaeg)",
|
||||
"description": "Genvaeg foer vanliga soekomraaden. Aasidosaetter alla andra tidsalternativ. today/tomorrow = hela kalenderdagen, remaining_today = nu till midnatt, next_24h/next_48h = rullande foenster fraen nu."
|
||||
},
|
||||
"max_price_level": {
|
||||
"name": "Maximal prisnivaae",
|
||||
"description": "Ta bara med intervall paa eller under denna Tibber-prisnivaae. very_cheap = mest restriktivt, very_expensive = ingen begraensning."
|
||||
},
|
||||
"min_price_level": {
|
||||
"name": "Minimal prisnivaae",
|
||||
"description": "Ta bara med intervall paa eller oever denna Tibber-prisnivaae. Anvaendbart foer find_most_expensive foer att fokusera paa verkligt dyra intervall."
|
||||
},
|
||||
"include_comparison_details": {
|
||||
"name": "Inkludera jaemfoerelsdetaljer",
|
||||
"description": "Berika price_comparison-resultatet med ytterligare faelt: comparison_price_min, comparison_price_max och (endast block) comparison_window_end."
|
||||
},
|
||||
"power_profile": {
|
||||
"name": "Effektprofil",
|
||||
"description": "Variabel effektfoerbruekning i watt per 15-minutersintervall. Om instaellt, aaterspeglar estimated_total_cost faktisk foerbruekning istaellet foer en fast 1 kW-last."
|
||||
}
|
||||
}
|
||||
},
|
||||
"find_most_expensive_hours": {
|
||||
"name": "Hitta dyraste timmarna",
|
||||
"description": "Hittar de dyraste intervallen för en given total varaktighet, inte nödvändigtvis sammanhängande. Användbart för att identifiera topprisperioder som bör undvikas. Returnerar ett schema av intervaller grupperade i sammanhängande segment.",
|
||||
"sections": {
|
||||
"search_range": {
|
||||
"name": "Soekomraade",
|
||||
"description": "Definiera tidsfoenstret att soeka inom."
|
||||
},
|
||||
"time_alternatives": {
|
||||
"name": "Alternativa tidsinstaellningar",
|
||||
"description": "Alternativa saett att definiera soekomraadet via tidpunkt och offset."
|
||||
},
|
||||
"price_filter": {
|
||||
"name": "Prisnivaaefilter",
|
||||
"description": "Begraensa soekningen till intervall inom det angivna prisnivaaeintervallet."
|
||||
},
|
||||
"output": {
|
||||
"name": "Utdataalternativ",
|
||||
"description": "Styr kostnadsuppskattning och jaemfoerelseresultat."
|
||||
}
|
||||
},
|
||||
"fields": {
|
||||
"entry_id": {
|
||||
"name": "Entry-ID",
|
||||
"description": "Config entry-ID för Tibber-integrationen."
|
||||
},
|
||||
"duration": {
|
||||
"name": "Varaktighet",
|
||||
"description": "Dyr total tid att hitta. Avrundas automatiskt uppåt till närmaste kvart. Maximum: 24 timmar."
|
||||
},
|
||||
"search_start": {
|
||||
"name": "Sökstart",
|
||||
"description": "Start av sökintervallet som exakt datum och tid. Högsta prioritet — åsidosätter alla andra startalternativ. Standard är nu om inte angivet."
|
||||
},
|
||||
"search_end": {
|
||||
"name": "Sökslut",
|
||||
"description": "Slut av sökintervallet som exakt datum och tid. Högsta prioritet — åsidosätter alla andra slutalternativ. Standard är slutet av imorgon om inte angivet."
|
||||
},
|
||||
"search_start_time": {
|
||||
"name": "Sökstart-klockslag",
|
||||
"description": "Alternativ: Börja söka från detta klockslag. Kombinera med dagförskjutning. Ignoreras om Sökstart (datum/tid) är satt."
|
||||
},
|
||||
"search_start_day_offset": {
|
||||
"name": "Sökstart dagförskjutning",
|
||||
"description": "Dagförskjutning för Sökstart-klockslag. -7 till 2: -1 = igår, 0 = idag, 1 = imorgon. Negativa värden söker i det förflutna. Används bara med Sökstart-klockslag."
|
||||
},
|
||||
"search_end_time": {
|
||||
"name": "Sökslut-klockslag",
|
||||
"description": "Alternativ: Sök till detta klockslag. Kombinera med dagförskjutning. Ignoreras om Sökslut (datum/tid) är satt."
|
||||
},
|
||||
"search_end_day_offset": {
|
||||
"name": "Sökslut dagförskjutning",
|
||||
"description": "Dagförskjutning för Sökslut-klockslag. -7 till 2: -1 = igår, 0 = idag, 1 = imorgon. Negativa värden söker i det förflutna. Används bara med Sökslut-klockslag."
|
||||
},
|
||||
"search_start_offset_minutes": {
|
||||
"name": "Sökstart-förskjutning (minuter)",
|
||||
"description": "Alternativ: Börja söka detta antal minuter från nu. Positivt = framtid (60 = om 1 timme), negativt = förflutet (-60 = 1 timme sedan). Ignoreras om Sökstart eller Sökstart-klockslag är satt."
|
||||
},
|
||||
"search_end_offset_minutes": {
|
||||
"name": "Sökslut-förskjutning (minuter)",
|
||||
"description": "Alternativ: Sluta söka detta antal minuter från nu. Positivt = framtid (480 = om 8 timmar), negativt = förflutet (-60 = 1 timme sedan). Ignoreras om Sökslut eller Sökslut-klockslag är satt."
|
||||
},
|
||||
"include_current_interval": {
|
||||
"name": "Inkludera aktuellt intervall",
|
||||
"description": "Inkludera det pågående 15-minutersintervallet i sökningen. När aktiverat (standard), börjar sökningen vid början av det aktuella intervallet så att det kan vara en del av resultatet."
|
||||
},
|
||||
"min_segment_duration": {
|
||||
"name": "Minsta segmentvaraktighet",
|
||||
"description": "Minsta sammanhängande körtid. Förhindrar snabb av/på-cykling för enheter med minsta körtider. Avrundas automatiskt uppåt till närmaste kvart. Standard: 15 minuter. Maximum: 4 timmar."
|
||||
},
|
||||
"use_base_unit": {
|
||||
"name": "Använd basvaluta",
|
||||
"description": "Tvinga priser i basvaluta (EUR, NOK) istället för konfigurerad visningsenhet (ct, öre). Användbart för beräkningar."
|
||||
},
|
||||
"search_scope": {
|
||||
"name": "Soekumfaang (genvaeg)",
|
||||
"description": "Genvaeg foer vanliga soekomraaden. Aasidosaetter alla andra tidsalternativ. today/tomorrow = hela kalenderdagen, remaining_today = nu till midnatt, next_24h/next_48h = rullande foenster fraen nu."
|
||||
},
|
||||
"max_price_level": {
|
||||
"name": "Maximal prisnivaae",
|
||||
"description": "Ta bara med intervall paa eller under denna Tibber-prisnivaae. very_cheap = mest restriktivt, very_expensive = ingen begraensning."
|
||||
},
|
||||
"min_price_level": {
|
||||
"name": "Minimal prisnivaae",
|
||||
"description": "Ta bara med intervall paa eller oever denna Tibber-prisnivaae. Anvaendbart foer find_most_expensive foer att fokusera paa verkligt dyra intervall."
|
||||
},
|
||||
"include_comparison_details": {
|
||||
"name": "Inkludera jaemfoerelsdetaljer",
|
||||
"description": "Berika price_comparison-resultatet med ytterligare faelt: comparison_price_min, comparison_price_max och (endast block) comparison_window_end."
|
||||
},
|
||||
"power_profile": {
|
||||
"name": "Effektprofil",
|
||||
"description": "Variabel effektfoerbruekning i watt per 15-minutersintervall. Om instaellt, aaterspeglar estimated_total_cost faktisk foerbruekning istaellet foer en fast 1 kW-last."
|
||||
}
|
||||
}
|
||||
},
|
||||
"find_cheapest_schedule": {
|
||||
"name": "Hitta billigaste schema",
|
||||
"description": "Schemalaggar flera apparater optimalt utan tidsoeverlapp. Varje uppgift tilldelas det billigaste tillgaengliga sammanhangande tidsfoenster.",
|
||||
"sections": {
|
||||
"scheduling_options": {
|
||||
"name": "Schemalagningsalternativ",
|
||||
"description": "Konfigurera uppgifter och pauser mellan dem."
|
||||
},
|
||||
"search_range": {
|
||||
"name": "Soekomraade",
|
||||
"description": "Definiera tidsfoenstret att soeka inom."
|
||||
},
|
||||
"time_alternatives": {
|
||||
"name": "Alternativa tidsinstaellningar",
|
||||
"description": "Alternativa saett att definiera soekomraadet via tidpunkt och offset."
|
||||
},
|
||||
"price_filter": {
|
||||
"name": "Prisnivaaefilter",
|
||||
"description": "Begraensa soekningen till intervall inom det angivna prisnivaaeintervallet."
|
||||
},
|
||||
"output": {
|
||||
"name": "Utdataalternativ",
|
||||
"description": "Styr kostnadsuppskattning och jaemfoerelseresultat."
|
||||
}
|
||||
},
|
||||
"fields": {
|
||||
"entry_id": {
|
||||
"name": "Entry-ID",
|
||||
"description": "Config entry-ID för Tibber-integrationen."
|
||||
},
|
||||
"tasks": {
|
||||
"name": "Uppgifter",
|
||||
"description": "Lista med uppgifter att schemalagga. Varje uppgift kraever name (text) och duration (hh:mm:ss). Valfritt power_profile (watt per 15-min-intervall). Maximalt 4 uppgifter."
|
||||
},
|
||||
"gap_minutes": {
|
||||
"name": "Paus mellan uppgifter (minuter)",
|
||||
"description": "Minsta paus i minuter mellan paa varandra foeoljande schemalagda uppgifter. Avrundas uppaat till 15 minuter. Standard: 0 (ingen paus)."
|
||||
},
|
||||
"search_scope": {
|
||||
"name": "Soekumfaang (genvaeg)",
|
||||
"description": "Genvaeg foer vanliga soekomraaden. Aasidosaetter alla andra tidsalternativ. today/tomorrow = hela kalenderdagen, remaining_today = nu till midnatt, next_24h/next_48h = rullande foenster fraen nu."
|
||||
},
|
||||
"search_start": {
|
||||
"name": "Sökstart",
|
||||
"description": "Start av sökintervallet som exakt datum och tid. Högsta prioritet — åsidosätter alla andra startalternativ. Standard är nu om inte angivet."
|
||||
},
|
||||
"search_end": {
|
||||
"name": "Sökslut",
|
||||
"description": "Slut av sökintervallet som exakt datum och tid. Högsta prioritet — åsidosätter alla andra slutalternativ. Standard är slutet av imorgon om inte angivet."
|
||||
},
|
||||
"search_start_time": {
|
||||
"name": "Sökstart-klockslag",
|
||||
"description": "Alternativ: Börja söka från detta klockslag. Kombinera med dagförskjutning. Ignoreras om Sökstart (datum/tid) är satt."
|
||||
},
|
||||
"search_start_day_offset": {
|
||||
"name": "Sökstart dagförskjutning",
|
||||
"description": "Dagförskjutning för Sökstart-klockslag. -7 till 2: -1 = igår, 0 = idag, 1 = imorgon. Negativa värden söker i det förflutna. Används bara med Sökstart-klockslag."
|
||||
},
|
||||
"search_end_time": {
|
||||
"name": "Sökslut-klockslag",
|
||||
"description": "Alternativ: Sök till detta klockslag. Kombinera med dagförskjutning. Ignoreras om Sökslut (datum/tid) är satt."
|
||||
},
|
||||
"search_end_day_offset": {
|
||||
"name": "Sökslut dagförskjutning",
|
||||
"description": "Dagförskjutning för Sökslut-klockslag. -7 till 2: -1 = igår, 0 = idag, 1 = imorgon. Negativa värden söker i det förflutna. Används bara med Sökslut-klockslag."
|
||||
},
|
||||
"search_start_offset_minutes": {
|
||||
"name": "Sökstart-förskjutning (minuter)",
|
||||
"description": "Alternativ: Börja söka detta antal minuter från nu. Positivt = framtid (60 = om 1 timme), negativt = förflutet (-60 = 1 timme sedan). Ignoreras om Sökstart eller Sökstart-klockslag är satt."
|
||||
},
|
||||
"search_end_offset_minutes": {
|
||||
"name": "Sökslut-förskjutning (minuter)",
|
||||
"description": "Alternativ: Sluta söka detta antal minuter från nu. Positivt = framtid (480 = om 8 timmar), negativt = förflutet (-60 = 1 timme sedan). Ignoreras om Sökslut eller Sökslut-klockslag är satt."
|
||||
},
|
||||
"include_current_interval": {
|
||||
"name": "Inkludera aktuellt intervall",
|
||||
"description": "Inkludera det pågående 15-minutersintervallet i sökningen. När aktiverat (standard), börjar sökningen vid början av det aktuella intervallet så att det kan vara en del av resultatet."
|
||||
},
|
||||
"max_price_level": {
|
||||
"name": "Maximal prisnivaae",
|
||||
"description": "Ta bara med intervall paa eller under denna Tibber-prisnivaae. very_cheap = mest restriktivt, very_expensive = ingen begraensning."
|
||||
},
|
||||
"min_price_level": {
|
||||
"name": "Minimal prisnivaae",
|
||||
"description": "Ta bara med intervall paa eller oever denna Tibber-prisnivaae. Anvaendbart foer find_most_expensive foer att fokusera paa verkligt dyra intervall."
|
||||
},
|
||||
"use_base_unit": {
|
||||
"name": "Använd basvaluta",
|
||||
"description": "Tvinga priser i basvaluta (EUR, NOK) istället för konfigurerad visningsenhet (ct, öre). Användbart för beräkningar."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"selector": {
|
||||
|
|
@ -1361,6 +1944,15 @@
|
|||
"median": "Median",
|
||||
"mean": "Aritmetiskt medelvärde"
|
||||
}
|
||||
},
|
||||
"search_scope": {
|
||||
"options": {
|
||||
"today": "Idag",
|
||||
"tomorrow": "Imorgon",
|
||||
"remaining_today": "Aaterstoden av idag",
|
||||
"next_24h": "Naesta 24 timmar",
|
||||
"next_48h": "Naesta 48 timmar"
|
||||
}
|
||||
}
|
||||
},
|
||||
"title": "Tibber Prisinformation & Betyg"
|
||||
|
|
|
|||
342
custom_components/tibber_prices/utils/price_window.py
Normal file
342
custom_components/tibber_prices/utils/price_window.py
Normal file
|
|
@ -0,0 +1,342 @@
|
|||
"""
|
||||
Pure algorithms for finding cheapest price windows.
|
||||
|
||||
Two independent algorithms:
|
||||
1. find_cheapest_contiguous_window — Sliding window for appliance scheduling
|
||||
2. find_cheapest_n_intervals — Cheapest N picks for flexible-load scheduling
|
||||
|
||||
These are stateless pure functions with no Home Assistant dependencies.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import statistics
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
|
||||
def find_cheapest_contiguous_window(
|
||||
intervals: list[dict[str, Any]],
|
||||
duration_intervals: int,
|
||||
*,
|
||||
reverse: bool = False,
|
||||
) -> dict[str, Any] | None:
|
||||
"""
|
||||
Find the cheapest (or most expensive) contiguous window of exactly N intervals.
|
||||
|
||||
Uses a sliding window algorithm (O(n)) to find the window with the
|
||||
lowest (or highest) average price.
|
||||
|
||||
Args:
|
||||
intervals: Sorted list of price interval dicts with 'startsAt' and 'total' keys.
|
||||
Must be pre-sorted by startsAt in ascending order.
|
||||
duration_intervals: Number of consecutive intervals required.
|
||||
reverse: If True, find the most expensive window instead of cheapest.
|
||||
|
||||
Returns:
|
||||
Dict with window details (start, end, intervals, statistics),
|
||||
or None if not enough intervals available.
|
||||
|
||||
"""
|
||||
n = len(intervals)
|
||||
if n == 0 or duration_intervals <= 0 or n < duration_intervals:
|
||||
return None
|
||||
|
||||
# Calculate initial window sum
|
||||
window_sum = sum(intervals[i]["total"] for i in range(duration_intervals))
|
||||
best_sum = window_sum
|
||||
best_start = 0
|
||||
|
||||
# Slide the window
|
||||
for i in range(1, n - duration_intervals + 1):
|
||||
window_sum += intervals[i + duration_intervals - 1]["total"]
|
||||
window_sum -= intervals[i - 1]["total"]
|
||||
if (window_sum > best_sum) if reverse else (window_sum < best_sum):
|
||||
best_sum = window_sum
|
||||
best_start = i
|
||||
|
||||
best_intervals = intervals[best_start : best_start + duration_intervals]
|
||||
return {
|
||||
"start": best_intervals[0]["startsAt"],
|
||||
"end_interval_start": best_intervals[-1]["startsAt"],
|
||||
"intervals": best_intervals,
|
||||
}
|
||||
|
||||
|
||||
def find_cheapest_n_intervals(
|
||||
intervals: list[dict[str, Any]],
|
||||
count: int,
|
||||
min_segment_intervals: int = 1,
|
||||
*,
|
||||
reverse: bool = False,
|
||||
) -> dict[str, Any] | None:
|
||||
"""
|
||||
Find the cheapest (or most expensive) N intervals, not necessarily contiguous.
|
||||
|
||||
Picks the cheapest (or most expensive) intervals by price, then groups them
|
||||
into contiguous segments. If min_segment_intervals > 1, short segments are
|
||||
discarded and replaced with next-cheapest/most-expensive available intervals
|
||||
until all segments meet the minimum length.
|
||||
|
||||
Args:
|
||||
intervals: Sorted list of price interval dicts with 'startsAt' and 'total' keys.
|
||||
Must be pre-sorted by startsAt in ascending order.
|
||||
count: Number of intervals to select.
|
||||
min_segment_intervals: Minimum contiguous length for each segment.
|
||||
Default 1 means no constraint.
|
||||
reverse: If True, find the most expensive intervals instead of cheapest.
|
||||
|
||||
Returns:
|
||||
Dict with schedule details (segments, intervals, statistics),
|
||||
or None if not enough intervals available.
|
||||
|
||||
"""
|
||||
n = len(intervals)
|
||||
if n == 0 or count <= 0 or n < count:
|
||||
return None
|
||||
|
||||
if min_segment_intervals <= 1:
|
||||
# Simple case: pick cheapest/most expensive N, then sort chronologically
|
||||
indexed = [(i, iv) for i, iv in enumerate(intervals)]
|
||||
indexed.sort(key=lambda x: x[1]["total"], reverse=reverse)
|
||||
selected_indices = sorted(idx for idx, _ in indexed[:count])
|
||||
selected = [intervals[i] for i in selected_indices]
|
||||
segments = group_intervals_into_segments(selected)
|
||||
return {
|
||||
"intervals": selected,
|
||||
"segments": segments,
|
||||
}
|
||||
|
||||
# Complex case: enforce minimum segment length
|
||||
return _find_with_min_segment(intervals, count, min_segment_intervals, reverse=reverse)
|
||||
|
||||
|
||||
def _find_with_min_segment(
|
||||
intervals: list[dict[str, Any]],
|
||||
count: int,
|
||||
min_segment: int,
|
||||
*,
|
||||
reverse: bool = False,
|
||||
) -> dict[str, Any] | None:
|
||||
"""
|
||||
Find cheapest/most expensive N intervals with minimum segment length constraint.
|
||||
|
||||
Iteratively picks intervals, discards segments that are too
|
||||
short, and replaces them with next-best alternatives.
|
||||
|
||||
Converges in at most `count` iterations (worst case: every replacement
|
||||
creates a new short segment that gets discarded).
|
||||
"""
|
||||
n = len(intervals)
|
||||
|
||||
# Build index lookup: interval original index → position
|
||||
# Price-sorted indices for picking cheapest/most expensive available
|
||||
price_order = sorted(range(n), key=lambda i: intervals[i]["total"], reverse=reverse)
|
||||
|
||||
selected: set[int] = set()
|
||||
excluded: set[int] = set()
|
||||
|
||||
# Initial pick: cheapest 'count' intervals
|
||||
picked = 0
|
||||
for idx in price_order:
|
||||
if picked >= count:
|
||||
break
|
||||
if idx not in excluded:
|
||||
selected.add(idx)
|
||||
picked += 1
|
||||
|
||||
if len(selected) < count:
|
||||
return None
|
||||
|
||||
# Iterative refinement: discard short segments, replace with next-cheapest
|
||||
max_iterations = count + 1 # Safety bound
|
||||
for _ in range(max_iterations):
|
||||
sorted_selected = sorted(selected)
|
||||
segments = _group_indices_into_segments(sorted_selected)
|
||||
|
||||
short_segments = [seg for seg in segments if len(seg) < min_segment]
|
||||
if not short_segments:
|
||||
break # All segments meet minimum length
|
||||
|
||||
# Exclude all indices in short segments
|
||||
for seg in short_segments:
|
||||
for idx in seg:
|
||||
selected.discard(idx)
|
||||
excluded.add(idx)
|
||||
|
||||
# Refill from price order
|
||||
needed = count - len(selected)
|
||||
for idx in price_order:
|
||||
if needed <= 0:
|
||||
break
|
||||
if idx not in selected and idx not in excluded:
|
||||
selected.add(idx)
|
||||
needed -= 1
|
||||
|
||||
if len(selected) < count:
|
||||
# Not enough intervals available after exclusions
|
||||
# Return best effort with what we have
|
||||
break
|
||||
|
||||
sorted_selected = sorted(selected)
|
||||
result_intervals = [intervals[i] for i in sorted_selected]
|
||||
segments = group_intervals_into_segments(result_intervals)
|
||||
|
||||
return {
|
||||
"intervals": result_intervals,
|
||||
"segments": segments,
|
||||
}
|
||||
|
||||
|
||||
def _group_indices_into_segments(indices: list[int]) -> list[list[int]]:
|
||||
"""Group sorted integer indices into contiguous runs."""
|
||||
if not indices:
|
||||
return []
|
||||
|
||||
segments: list[list[int]] = [[indices[0]]]
|
||||
for i in range(1, len(indices)):
|
||||
if indices[i] == indices[i - 1] + 1:
|
||||
segments[-1].append(indices[i])
|
||||
else:
|
||||
segments.append([indices[i]])
|
||||
return segments
|
||||
|
||||
|
||||
def group_intervals_into_segments(
|
||||
intervals: list[dict[str, Any]],
|
||||
) -> list[dict[str, Any]]:
|
||||
"""
|
||||
Group chronologically sorted intervals into contiguous segments.
|
||||
|
||||
Two intervals are contiguous if the second starts exactly 15 minutes
|
||||
after the first.
|
||||
|
||||
Args:
|
||||
intervals: Chronologically sorted interval dicts with 'startsAt' key.
|
||||
|
||||
Returns:
|
||||
List of segment dicts, each containing:
|
||||
- start: ISO timestamp of first interval
|
||||
- end_interval_start: ISO timestamp of last interval in segment
|
||||
- duration_minutes: Total segment duration
|
||||
- interval_count: Number of intervals in segment
|
||||
- intervals: The interval dicts in this segment
|
||||
|
||||
"""
|
||||
if not intervals:
|
||||
return []
|
||||
|
||||
segments: list[dict[str, Any]] = []
|
||||
current_segment: list[dict[str, Any]] = [intervals[0]]
|
||||
|
||||
for i in range(1, len(intervals)):
|
||||
prev_start = _parse_timestamp(intervals[i - 1]["startsAt"])
|
||||
curr_start = _parse_timestamp(intervals[i]["startsAt"])
|
||||
|
||||
if curr_start - prev_start == timedelta(minutes=15):
|
||||
current_segment.append(intervals[i])
|
||||
else:
|
||||
segments.append(_build_segment(current_segment))
|
||||
current_segment = [intervals[i]]
|
||||
|
||||
segments.append(_build_segment(current_segment))
|
||||
return segments
|
||||
|
||||
|
||||
def _build_segment(intervals: list[dict[str, Any]]) -> dict[str, Any]:
|
||||
"""Build a segment dict from a list of contiguous intervals."""
|
||||
return {
|
||||
"start": intervals[0]["startsAt"],
|
||||
"end_interval_start": intervals[-1]["startsAt"],
|
||||
"duration_minutes": len(intervals) * 15,
|
||||
"interval_count": len(intervals),
|
||||
"intervals": intervals,
|
||||
}
|
||||
|
||||
|
||||
def calculate_window_statistics(
|
||||
intervals: list[dict[str, Any]],
|
||||
unit_factor: int = 1,
|
||||
round_decimals: int = 4,
|
||||
interval_minutes: int = 15,
|
||||
power_profile: list[int] | None = None,
|
||||
) -> dict[str, float | None]:
|
||||
"""
|
||||
Calculate price statistics for a list of intervals.
|
||||
|
||||
Args:
|
||||
intervals: List of interval dicts with 'total' key (base currency).
|
||||
unit_factor: Multiplication factor for display (100 for subunit, 1 for base).
|
||||
round_decimals: Number of decimal places for rounding.
|
||||
interval_minutes: Duration of each interval in minutes (for cost calculation).
|
||||
power_profile: Optional list of power values in watts, one per interval.
|
||||
If shorter than the interval list, the last value is repeated.
|
||||
When provided, estimated_total_cost reflects variable power draw instead
|
||||
of a constant 1 kW load, and estimated_load_kwh is added to the result.
|
||||
|
||||
Returns:
|
||||
Dict with price_mean, price_median, price_min, price_max, price_spread,
|
||||
and estimated_total_cost (total cost for the given or constant 1 kW load).
|
||||
When power_profile is provided, also includes estimated_load_kwh.
|
||||
|
||||
"""
|
||||
if not intervals:
|
||||
result: dict[str, float | None] = {
|
||||
"price_mean": None,
|
||||
"price_median": None,
|
||||
"price_min": None,
|
||||
"price_max": None,
|
||||
"price_spread": None,
|
||||
"estimated_total_cost": None,
|
||||
}
|
||||
if power_profile is not None:
|
||||
result["estimated_load_kwh"] = None
|
||||
return result
|
||||
|
||||
prices = [iv["total"] * unit_factor for iv in intervals]
|
||||
mean = round(statistics.mean(prices), round_decimals)
|
||||
median = round(statistics.median(prices), round_decimals)
|
||||
price_min = round(min(prices), round_decimals)
|
||||
price_max = round(max(prices), round_decimals)
|
||||
spread = round(price_max - price_min, round_decimals)
|
||||
|
||||
hours_per_interval = interval_minutes / 60
|
||||
|
||||
if power_profile is not None:
|
||||
# Extend profile to cover all intervals by repeating the last value
|
||||
last_watts = power_profile[-1] if power_profile else 1000
|
||||
profile = list(power_profile) + [last_watts] * max(0, len(intervals) - len(power_profile))
|
||||
load_kwh_per_interval = [w / 1000 * hours_per_interval for w in profile[: len(intervals)]]
|
||||
estimated_cost = round(
|
||||
sum(p * kwh for p, kwh in zip(prices, load_kwh_per_interval, strict=False)), round_decimals
|
||||
)
|
||||
estimated_load_kwh = round(sum(load_kwh_per_interval), round_decimals)
|
||||
return {
|
||||
"price_mean": mean,
|
||||
"price_median": median,
|
||||
"price_min": price_min,
|
||||
"price_max": price_max,
|
||||
"price_spread": spread,
|
||||
"estimated_total_cost": estimated_cost,
|
||||
"estimated_load_kwh": estimated_load_kwh,
|
||||
}
|
||||
|
||||
# Estimated cost for running a 1 kW constant load during all intervals
|
||||
# Each interval covers interval_minutes/60 hours, price is per kWh
|
||||
estimated_cost = round(sum(p * hours_per_interval for p in prices), round_decimals)
|
||||
|
||||
return {
|
||||
"price_mean": mean,
|
||||
"price_median": median,
|
||||
"price_min": price_min,
|
||||
"price_max": price_max,
|
||||
"price_spread": spread,
|
||||
"estimated_total_cost": estimated_cost,
|
||||
}
|
||||
|
||||
|
||||
def _parse_timestamp(ts: str | datetime) -> datetime:
|
||||
"""Parse an ISO timestamp string or pass through a datetime object."""
|
||||
if isinstance(ts, datetime):
|
||||
return ts
|
||||
return datetime.fromisoformat(ts)
|
||||
|
|
@ -1,12 +1,12 @@
|
|||
# Actions (Services)
|
||||
# Actions Overview
|
||||
|
||||
Home Assistant now surfaces these backend service endpoints as **Actions** in the UI (for example, Developer Tools → Actions or the Action editor inside dashboards). Behind the scenes they are still Home Assistant services that use the `service:` key, but this guide uses the word “action” whenever we refer to the user interface.
|
||||
Tibber Prices provides **actions** (formerly called "services") that you can use in automations, scripts, and dashboards. Home Assistant surfaces them in **Developer Tools → Actions** and in the automation/script editor.
|
||||
|
||||
You can still call them from automations, scripts, and dashboards the same way as before (`service: tibber_prices.get_chartdata`, etc.), just remember that the frontend officially lists them as actions.
|
||||
Behind the scenes, YAML still uses the `service:` key — but the UI calls them "actions".
|
||||
|
||||
## Finding Your Entry ID
|
||||
## Finding Your Config Entry ID
|
||||
|
||||
Every action requires an `entry_id` parameter that tells Home Assistant which Tibber home (integration instance) to use. If you only have one home, there is still exactly one entry ID — you just need to know where to find it.
|
||||
Most actions accept an optional `entry_id` parameter that identifies the **config entry** (= integration instance) of the Tibber home you want to query. **If you only have one home configured, you can omit `entry_id` entirely** — the integration auto-selects your only config entry. If you have multiple homes, you need to specify which one.
|
||||
|
||||
### In the Action UI — no lookup needed
|
||||
|
||||
|
|
@ -25,349 +25,43 @@ When you write YAML directly (automations, scripts, Lovelace dashboard cards), y
|
|||
The ID looks like a long alphanumeric string, for example `01JKPC7AB3EF4GH5IJ6KL7MN8P`.
|
||||
|
||||
:::tip Multiple homes?
|
||||
If you have configured more than one Tibber home, each home has its own entry ID. Repeat the steps above for each integration card to get the individual IDs.
|
||||
If you have configured more than one Tibber home, each home has its own config entry ID. Repeat the steps above for each integration card to get the individual IDs.
|
||||
:::
|
||||
|
||||
## Available Actions
|
||||
## All Actions at a Glance
|
||||
|
||||
### tibber_prices.get_chartdata
|
||||
### Scheduling Actions
|
||||
|
||||
**Purpose:** Returns electricity price data in chart-friendly formats for visualization and analysis.
|
||||
Find the cheapest (or most expensive) time windows for your appliances. Ideal for automating when to run devices based on real price data.
|
||||
|
||||
**Key Features:**
|
||||
| Action | Description | Best For |
|
||||
|--------|-------------|----------|
|
||||
| [`find_cheapest_block`](scheduling-actions.md#find-cheapest-block) | Cheapest contiguous window | Dishwasher, washing machine, dryer |
|
||||
| [`find_cheapest_hours`](scheduling-actions.md#find-cheapest-hours) | Cheapest N hours (non-contiguous OK) | EV charging, battery storage, pool pump |
|
||||
| [`find_cheapest_schedule`](scheduling-actions.md#find-cheapest-schedule) | Multiple appliances, no overlap | Dishwasher + washing machine overnight |
|
||||
| [`find_most_expensive_block`](scheduling-actions.md#find-most-expensive-block) | Most expensive contiguous window | Peak avoidance, battery discharge |
|
||||
| [`find_most_expensive_hours`](scheduling-actions.md#find-most-expensive-hours) | Most expensive N hours | Demand response, consumption shifting |
|
||||
|
||||
- **Flexible Output Formats**: Array of objects or array of arrays
|
||||
- **Time Range Selection**: Filter by day (yesterday, today, tomorrow)
|
||||
- **Price Filtering**: Filter by price level or rating
|
||||
- **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**: Override integration default - use base (€/kWh, kr/kWh) or subunit (ct/kWh, øre/kWh)
|
||||
**→ [Scheduling Actions — Full Guide](scheduling-actions.md)** with parameters, response formats, decision flowchart, and automation examples.
|
||||
|
||||
**Basic Example:**
|
||||
### Chart & Visualization Actions
|
||||
|
||||
```yaml
|
||||
service: tibber_prices.get_chartdata
|
||||
data:
|
||||
entry_id: YOUR_CONFIG_ENTRY_ID
|
||||
day: ["today", "tomorrow"]
|
||||
output_format: array_of_objects
|
||||
response_variable: chart_data
|
||||
```
|
||||
Generate chart-ready data and ApexCharts configurations for your dashboards.
|
||||
|
||||
**Response Format:**
|
||||
| Action | Description |
|
||||
|--------|-------------|
|
||||
| [`get_chartdata`](chart-actions.md#tibber_pricesget_chartdata) | Price data in chart-friendly formats (arrays, filtering, rolling windows) |
|
||||
| [`get_apexcharts_yaml`](chart-actions.md#tibber_pricesget_apexcharts_yaml) | Auto-generated ApexCharts card configuration with color-coded price levels |
|
||||
|
||||
```json
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"start_time": "2025-11-17T00:00:00+01:00",
|
||||
"price_per_kwh": 0.2534
|
||||
},
|
||||
{
|
||||
"start_time": "2025-11-17T00:15:00+01:00",
|
||||
"price_per_kwh": 0.2498
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
**→ [Chart & Visualization Actions — Full Guide](chart-actions.md)** with parameters, examples, rolling window modes, and migration guide.
|
||||
|
||||
**Common Parameters:**
|
||||
### Data & Utility Actions
|
||||
|
||||
| Parameter | Description | Default |
|
||||
| ---------------- | ------------------------------------------- | ----------------------- |
|
||||
| `entry_id` | Integration entry ID (required) | - |
|
||||
| `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` |
|
||||
| `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) |
|
||||
Fetch raw price data or refresh cached information.
|
||||
|
||||
**Rolling Window Mode:**
|
||||
| Action | Description |
|
||||
|--------|-------------|
|
||||
| [`get_price`](data-actions.md#tibber_pricesget_price) | Fetch raw price intervals for any time range (with intelligent caching) |
|
||||
| [`refresh_user_data`](data-actions.md#tibber_pricesrefresh_user_data) | Force-refresh user data (homes, subscriptions) from Tibber API |
|
||||
|
||||
Omit the `day` parameter to get a dynamic 48-hour rolling window that automatically adapts to data availability:
|
||||
|
||||
```yaml
|
||||
service: tibber_prices.get_chartdata
|
||||
data:
|
||||
entry_id: YOUR_CONFIG_ENTRY_ID
|
||||
# Omit 'day' for rolling window
|
||||
output_format: array_of_objects
|
||||
response_variable: chart_data
|
||||
```
|
||||
|
||||
**Behavior:**
|
||||
- **When tomorrow data available** (typically after ~13:00): Returns today + tomorrow
|
||||
- **When tomorrow data not available**: Returns yesterday + today
|
||||
|
||||
This is useful for charts that should always show a 48-hour window without manual day selection.
|
||||
|
||||
**Period Filter Example:**
|
||||
|
||||
Get best price periods as summaries instead of intervals:
|
||||
|
||||
```yaml
|
||||
service: tibber_prices.get_chartdata
|
||||
data:
|
||||
entry_id: YOUR_CONFIG_ENTRY_ID
|
||||
period_filter: best_price # or peak_price
|
||||
day: ["today", "tomorrow"]
|
||||
include_level: true
|
||||
include_rating_level: true
|
||||
response_variable: periods
|
||||
```
|
||||
|
||||
**Advanced Filtering:**
|
||||
|
||||
```yaml
|
||||
service: tibber_prices.get_chartdata
|
||||
data:
|
||||
entry_id: YOUR_CONFIG_ENTRY_ID
|
||||
level_filter: ["VERY_CHEAP", "CHEAP"] # Only cheap periods
|
||||
rating_level_filter: ["LOW"] # Only low-rated prices
|
||||
insert_nulls: segments # Add nulls at segment boundaries
|
||||
```
|
||||
|
||||
**Complete Documentation:**
|
||||
|
||||
For detailed parameter descriptions, open **Developer Tools → Actions** (the UI label) and select `tibber_prices.get_chartdata`. The inline documentation is still stored in `services.yaml` because actions are backed by services.
|
||||
|
||||
#### Energy & Tax Fields in get_chartdata
|
||||
|
||||
You can include the raw energy price (spot price) and/or tax component in chart data output. This is useful for visualizing how the total price is composed over time, or for feed-in calculations.
|
||||
|
||||
| Parameter | Description | Default |
|
||||
|-----------|-------------|---------|
|
||||
| `include_energy` | Include raw energy/spot price per data point | `false` |
|
||||
| `include_tax` | Include tax/fees component per data point | `false` |
|
||||
| `energy_field` | Custom field name for energy price | `energy_price` |
|
||||
| `tax_field` | Custom field name for tax | `tax` |
|
||||
|
||||
**Example: Chart with price composition**
|
||||
|
||||
```yaml
|
||||
service: tibber_prices.get_chartdata
|
||||
data:
|
||||
entry_id: YOUR_CONFIG_ENTRY_ID
|
||||
day: ["today", "tomorrow"]
|
||||
include_energy: true
|
||||
include_tax: true
|
||||
response_variable: chart_data
|
||||
```
|
||||
|
||||
Returns data points like:
|
||||
|
||||
```json
|
||||
{
|
||||
"start_time": "2025-11-17T14:00:00+01:00",
|
||||
"price_per_kwh": 25.34,
|
||||
"energy_price": 12.18,
|
||||
"tax": 13.16
|
||||
}
|
||||
```
|
||||
|
||||
**Use case — Solar feed-in chart:** Overlay the energy price (what you earn by exporting) alongside the total price to visualize the best export windows. See [Energy & Tax Attributes](sensors-energy-tax.md) for more use cases.
|
||||
|
||||
---
|
||||
|
||||
### tibber_prices.get_apexcharts_yaml
|
||||
|
||||
> ⚠️ **IMPORTANT:** This action generates a **basic example configuration** as a starting point, NOT a complete solution for all ApexCharts features.
|
||||
>
|
||||
> This integration is primarily a **data provider**. The generated YAML demonstrates how to use the `get_chartdata` action to fetch price data. Due to the segmented nature of our data (different time periods per series) and the use of Home Assistant's service API instead of entity attributes, many advanced ApexCharts features (like `in_header`, certain transformations) are **not compatible** or require manual customization.
|
||||
>
|
||||
> **You are welcome to customize** the generated YAML for your specific needs, but comprehensive ApexCharts configuration support is beyond the scope of this integration. Community contributions with improved configurations are always appreciated!
|
||||
>
|
||||
> **For custom solutions:** Use the `get_chartdata` action directly to build your own charts with full control over the data format and visualization.
|
||||
|
||||
**Purpose:** Generates a basic ApexCharts card YAML configuration example for visualizing electricity prices with automatic color-coding by price level.
|
||||
|
||||
**Prerequisites:**
|
||||
- [ApexCharts Card](https://github.com/RomRider/apexcharts-card) (required for all configurations)
|
||||
- [Config Template Card](https://github.com/iantrich/config-template-card) (required only for rolling window modes - enables dynamic Y-axis scaling)
|
||||
|
||||
**✨ Key Features:**
|
||||
|
||||
- **Automatic Color-Coded Series**: Separate series for each price level (VERY_CHEAP, CHEAP, NORMAL, EXPENSIVE, VERY_EXPENSIVE) or rating (LOW, NORMAL, HIGH)
|
||||
- **Dynamic Y-Axis Scaling**: Rolling window modes automatically use `chart_metadata` sensor for optimal Y-axis bounds
|
||||
- **Best Price Period Highlights**: Optional vertical bands showing detected best price periods
|
||||
- **Translated Labels**: Automatically uses your Home Assistant language setting
|
||||
- **Clean Gap Visualization**: Proper NULL insertion for missing data segments
|
||||
|
||||
**Quick Example:**
|
||||
|
||||
```yaml
|
||||
service: tibber_prices.get_apexcharts_yaml
|
||||
data:
|
||||
entry_id: YOUR_CONFIG_ENTRY_ID
|
||||
day: today # Optional: yesterday, today, tomorrow, rolling_window, rolling_window_autozoom
|
||||
level_type: rating_level # or "level" for 5-level classification
|
||||
highlight_best_price: true # Show best price period overlays
|
||||
response_variable: apexcharts_config
|
||||
```
|
||||
|
||||
**Day Parameter Options:**
|
||||
|
||||
- **Fixed days** (`yesterday`, `today`, `tomorrow`): Static 24-hour views, no additional dependencies
|
||||
- **Rolling Window** (default when omitted or `rolling_window`): Dynamic 48-hour window that automatically shifts between yesterday+today and today+tomorrow based on data availability
|
||||
- **✨ Includes dynamic Y-axis scaling** via `chart_metadata` sensor
|
||||
- **Rolling Window (Auto-Zoom)** (`rolling_window_autozoom`): Same as rolling window, but additionally zooms in progressively (2h lookback + remaining time until midnight, graph span decreases every 15 minutes)
|
||||
- **✨ Includes dynamic Y-axis scaling** via `chart_metadata` sensor
|
||||
|
||||
**Dynamic Y-Axis Scaling (Rolling Window Modes):**
|
||||
|
||||
Rolling window configurations automatically integrate with the `chart_metadata` sensor for optimal chart appearance:
|
||||
|
||||
- **Automatic bounds**: Y-axis min/max adjust to data range
|
||||
- **No manual configuration**: Works out of the box if sensor is enabled
|
||||
- **Fallback behavior**: If sensor is disabled, uses ApexCharts auto-scaling
|
||||
- **Real-time updates**: Y-axis adapts when price data changes
|
||||
|
||||
**Example: Today's Prices (Static View)**
|
||||
|
||||
```yaml
|
||||
service: tibber_prices.get_apexcharts_yaml
|
||||
data:
|
||||
entry_id: YOUR_CONFIG_ENTRY_ID
|
||||
day: today
|
||||
level_type: rating_level
|
||||
response_variable: config
|
||||
|
||||
# Use in dashboard:
|
||||
type: custom:apexcharts-card
|
||||
# ... paste generated config
|
||||
```
|
||||
|
||||
**Example: Rolling 48h Window (Dynamic View)**
|
||||
|
||||
```yaml
|
||||
service: tibber_prices.get_apexcharts_yaml
|
||||
data:
|
||||
entry_id: YOUR_CONFIG_ENTRY_ID
|
||||
# Omit 'day' for rolling window (or use 'rolling_window')
|
||||
level_type: level # 5-level classification
|
||||
highlight_best_price: true
|
||||
response_variable: config
|
||||
|
||||
# Use in dashboard:
|
||||
type: custom:config-template-card
|
||||
entities:
|
||||
- binary_sensor.<home_name>_tomorrow_s_data_available
|
||||
- sensor.<home_name>_chart_metadata # For dynamic Y-axis
|
||||
card:
|
||||
# ... paste generated config
|
||||
```
|
||||
|
||||
**Screenshots:**
|
||||
|
||||
_Screenshots coming soon for all 4 modes: today, tomorrow, rolling_window, rolling_window_autozoom_
|
||||
|
||||
**Level Type Options:**
|
||||
|
||||
- **`rating_level`** (default): 3 series (LOW, NORMAL, HIGH) - based on your personal thresholds
|
||||
- **`level`**: 5 series (VERY_CHEAP, CHEAP, NORMAL, EXPENSIVE, VERY_EXPENSIVE) - absolute price ranges
|
||||
|
||||
**Best Price Period Highlights:**
|
||||
|
||||
When `highlight_best_price: true`:
|
||||
- Vertical bands overlay the chart showing detected best price periods
|
||||
- Tooltip shows "Best Price Period" label when hovering over highlighted areas
|
||||
- Only appears when best price periods are configured and detected
|
||||
|
||||
**Important Notes:**
|
||||
|
||||
- **Config Template Card** is only required for rolling window modes (enables dynamic Y-axis)
|
||||
- Fixed day views (`today`, `tomorrow`, `yesterday`) work with ApexCharts Card alone
|
||||
- Generated YAML is a starting point - customize colors, styling, features as needed
|
||||
- All labels are automatically translated to your Home Assistant language
|
||||
|
||||
Use the response in Lovelace dashboards by copying the generated YAML.
|
||||
|
||||
**Documentation:** Refer to **Developer Tools → Actions** for descriptions of the fields exposed by this action.
|
||||
|
||||
---
|
||||
|
||||
### tibber_prices.refresh_user_data
|
||||
|
||||
**Purpose:** Forces an immediate refresh of user data (homes, subscriptions) from the Tibber API.
|
||||
|
||||
**Example:**
|
||||
|
||||
```yaml
|
||||
service: tibber_prices.refresh_user_data
|
||||
data:
|
||||
entry_id: YOUR_CONFIG_ENTRY_ID
|
||||
```
|
||||
|
||||
**Note:** User data is cached for 24 hours. Trigger this action only when you need immediate updates (e.g., after changing Tibber subscriptions).
|
||||
|
||||
---
|
||||
|
||||
### tibber_prices.get_price
|
||||
|
||||
**Purpose:** Fetches raw price interval data for any time range. Uses intelligent caching — only intervals not already cached are fetched from the Tibber API.
|
||||
|
||||
**Parameters:**
|
||||
|
||||
| Parameter | Description | Required |
|
||||
|-----------|-------------|----------|
|
||||
| `entry_id` | Integration entry ID | Yes |
|
||||
| `start_time` | Start of the time range | Yes |
|
||||
| `end_time` | End of the time range | Yes |
|
||||
|
||||
**Example:**
|
||||
|
||||
```yaml
|
||||
service: tibber_prices.get_price
|
||||
data:
|
||||
entry_id: YOUR_CONFIG_ENTRY_ID
|
||||
start_time: "2025-11-01T00:00:00"
|
||||
end_time: "2025-11-02T00:00:00"
|
||||
response_variable: price_data
|
||||
```
|
||||
|
||||
**Response Format:**
|
||||
|
||||
```json
|
||||
{
|
||||
"home_id": "abc-123",
|
||||
"start_time": "2025-11-01T00:00:00+01:00",
|
||||
"end_time": "2025-11-02T00:00:00+01:00",
|
||||
"interval_count": 96,
|
||||
"price_info": [
|
||||
{
|
||||
"startsAt": "2025-11-01T00:00:00+01:00",
|
||||
"total": 0.2534,
|
||||
"energy": 0.1218,
|
||||
"tax": 0.1316
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Use cases:**
|
||||
- Fetching historical price data for analysis
|
||||
- Comparing prices across arbitrary date ranges
|
||||
- Building custom charts with historical data
|
||||
|
||||
**Note:** Times are automatically converted to your Tibber home's timezone. The interval pool caches previously fetched intervals, so repeated calls for the same range are fast.
|
||||
|
||||
---
|
||||
|
||||
## Migration from Chart Data Export Sensor
|
||||
|
||||
If you're still using the `sensor.<home_name>_chart_data_export` sensor, consider migrating to the `tibber_prices.get_chartdata` action:
|
||||
|
||||
**Benefits:**
|
||||
|
||||
- No HA restart required for configuration changes
|
||||
- More flexible filtering and formatting options
|
||||
- Better performance (on-demand instead of polling)
|
||||
- Future-proof (active development)
|
||||
|
||||
**Migration Steps:**
|
||||
|
||||
1. Note your current sensor configuration (Step 7 in Options Flow)
|
||||
2. Create automation/script that calls `tibber_prices.get_chartdata` with the same parameters
|
||||
3. Test the new approach
|
||||
4. Disable the old sensor when satisfied
|
||||
**→ [Data & Utility Actions — Full Guide](data-actions.md)** with parameters and response formats.
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
- [Price-Based Automations](#price-based-automations)
|
||||
- [Volatility-Aware Automations](#volatility-aware-automations)
|
||||
- [Best Hour Detection](#best-hour-detection)
|
||||
- [Scheduling Actions](#scheduling-actions)
|
||||
- [Charts & Visualizations](#charts--visualizations)
|
||||
|
||||
---
|
||||
|
|
@ -19,7 +20,9 @@
|
|||
>
|
||||
> These examples provide a good starting point but must be tailored to your individual Home Assistant setup.
|
||||
>
|
||||
> **Entity ID tip:** `<home_name>` is a placeholder for your Tibber home display name in Home Assistant. Entity IDs are derived from the displayed name (localized), so the exact slug may differ. **Can't find a sensor?** Use the **[Entity Reference (All Languages)](sensor-reference.md)** to search by name in your language.
|
||||
:::tip Entity ID tip
|
||||
`<home_name>` is a placeholder for your Tibber home display name in Home Assistant. Entity IDs are derived from the displayed name (localized), so the exact slug may differ. **Can't find a sensor?** Use the **[Entity Reference (All Languages)](sensor-reference.md)** to search by name in your language.
|
||||
:::
|
||||
|
||||
## Price-Based Automations
|
||||
|
||||
|
|
@ -59,7 +62,7 @@ gantt
|
|||
This automation starts a flexible load when the best price period begins, but keeps it running as long as prices remain favorable — even after the period ends.
|
||||
|
||||
<details>
|
||||
<summary>Show YAML: Heat pump — extended cheap period</summary>
|
||||
<summary>Show YAML: Ride the Full Cheap Wave</summary>
|
||||
|
||||
```yaml
|
||||
automation:
|
||||
|
|
@ -120,7 +123,7 @@ automation:
|
|||
Use the trend to start slightly before the cheapest period — useful for appliances with warm-up time:
|
||||
|
||||
<details>
|
||||
<summary>Show YAML: Water heater — pre-heat before cheapest window</summary>
|
||||
<summary>Show YAML: Pre-Emptive Start</summary>
|
||||
|
||||
```yaml
|
||||
automation:
|
||||
|
|
@ -156,7 +159,7 @@ automation:
|
|||
Stop or reduce consumption when prices are climbing:
|
||||
|
||||
<details>
|
||||
<summary>Show YAML: EV charger — stop when prices rising</summary>
|
||||
<summary>Show YAML: Protect Against Rising Prices</summary>
|
||||
|
||||
```yaml
|
||||
automation:
|
||||
|
|
@ -196,7 +199,7 @@ Combine short-term and long-term trend sensors for smarter decisions. This examp
|
|||
- If long-term says `falling` → cheaper hours ahead, no rush
|
||||
|
||||
<details>
|
||||
<summary>Show YAML: Heat pump — multi-window trend strategy</summary>
|
||||
<summary>Show YAML: Multi-Window Trend Strategy</summary>
|
||||
|
||||
```yaml
|
||||
automation:
|
||||
|
|
@ -296,7 +299,7 @@ On days with low price variation, the difference between "cheap" and "expensive"
|
|||
**Best Practice:** Instead of checking a numeric percentage, this automation checks the sensor's classified state. This makes the automation simpler and respects the volatility thresholds you have configured centrally in the integration's options.
|
||||
|
||||
<details>
|
||||
<summary>Show YAML: Home battery — charge on best price (volatility-aware)</summary>
|
||||
<summary>Show YAML: Meaningful Price Variations</summary>
|
||||
|
||||
```yaml
|
||||
automation:
|
||||
|
|
@ -343,7 +346,7 @@ automation:
|
|||
This is the most robust approach. It trusts the "Best Price" classification on volatile days but adds a backup absolute price check for low-volatility days. This handles situations where prices are globally low, even if the daily variation is minimal.
|
||||
|
||||
<details>
|
||||
<summary>Show YAML: EV charging — combined volatility + absolute price strategy</summary>
|
||||
<summary>Show YAML: Volatility and Absolute Price Check</summary>
|
||||
|
||||
```yaml
|
||||
automation:
|
||||
|
|
@ -393,7 +396,7 @@ automation:
|
|||
For maximum simplicity, you can use the attributes of the `best_price_period` sensor itself. It contains the volatility classification for the day the period belongs to. This is especially useful for periods that span across midnight.
|
||||
|
||||
<details>
|
||||
<summary>Show YAML: Heat pump — smart heating using period volatility attribute</summary>
|
||||
<summary>Show YAML: Period Volatility Attribute</summary>
|
||||
|
||||
```yaml
|
||||
automation:
|
||||
|
|
@ -434,7 +437,7 @@ automation:
|
|||
Use future average sensors to determine the cheapest upcoming window for a timed appliance (e.g., dishwasher with 2-hour ECO program):
|
||||
|
||||
<details>
|
||||
<summary>Show YAML: Dishwasher — schedule for cheapest 2h window</summary>
|
||||
<summary>Show YAML: Best Time for an Appliance</summary>
|
||||
|
||||
```yaml
|
||||
automation:
|
||||
|
|
@ -476,7 +479,7 @@ automation:
|
|||
Get a push notification when the best price period begins:
|
||||
|
||||
<details>
|
||||
<summary>Show YAML: Notification — when cheap window starts</summary>
|
||||
<summary>Show YAML: Cheap Window Notification</summary>
|
||||
|
||||
```yaml
|
||||
automation:
|
||||
|
|
@ -502,6 +505,12 @@ automation:
|
|||
|
||||
---
|
||||
|
||||
## Scheduling Actions
|
||||
|
||||
> **Looking for scheduling actions?** The **[Scheduling Actions Guide](scheduling-actions.md)** covers `find_cheapest_block`, `find_cheapest_hours`, `find_cheapest_schedule`, and their "most expensive" counterparts — ideal for automations that need to find optimal time windows dynamically (e.g., EV charging, heat pump scheduling, appliance timing).
|
||||
|
||||
---
|
||||
|
||||
## Charts & Visualizations
|
||||
|
||||
> **Looking for chart configurations?** See the **[Chart Examples Guide](chart-examples.md)** for ApexCharts card configurations, rolling window modes, and more.
|
||||
|
|
|
|||
330
docs/user/docs/chart-actions.md
Normal file
330
docs/user/docs/chart-actions.md
Normal file
|
|
@ -0,0 +1,330 @@
|
|||
# Chart & Visualization Actions
|
||||
|
||||
Actions for generating chart data and ApexCharts configurations from your Tibber price data.
|
||||
|
||||
:::tip Entity ID tip
|
||||
`<home_name>` is a placeholder for your Tibber home display name in Home Assistant. Entity IDs are derived from the displayed name (localized), so the exact slug may differ. **Can't find a sensor?** Use the **[Entity Reference (All Languages)](sensor-reference.md)** to search by name in your language.
|
||||
:::
|
||||
|
||||
---
|
||||
|
||||
## tibber_prices.get_chartdata
|
||||
|
||||
**Purpose:** Returns electricity price data in chart-friendly formats for visualization and analysis.
|
||||
|
||||
**Key Features:**
|
||||
|
||||
- **Flexible Output Formats**: Array of objects or array of arrays
|
||||
- **Time Range Selection**: Filter by day (yesterday, today, tomorrow)
|
||||
- **Price Filtering**: Filter by price level or rating
|
||||
- **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**: Override integration default - use base (€/kWh, kr/kWh) or subunit (ct/kWh, øre/kWh)
|
||||
|
||||
**Basic Example:**
|
||||
|
||||
<details>
|
||||
<summary>Show YAML: Chart Data Action</summary>
|
||||
|
||||
```yaml
|
||||
service: tibber_prices.get_chartdata
|
||||
data:
|
||||
entry_id: YOUR_CONFIG_ENTRY_ID
|
||||
day: ["today", "tomorrow"]
|
||||
output_format: array_of_objects
|
||||
response_variable: chart_data
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
**Response Format:**
|
||||
|
||||
<details>
|
||||
<summary>Show JSON: Chart Data Response</summary>
|
||||
|
||||
```json
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"start_time": "2025-11-17T00:00:00+01:00",
|
||||
"price_per_kwh": 0.2534
|
||||
},
|
||||
{
|
||||
"start_time": "2025-11-17T00:15:00+01:00",
|
||||
"price_per_kwh": 0.2498
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
**Common Parameters:**
|
||||
|
||||
| Parameter | Description | Default |
|
||||
| ---------------- | ------------------------------------------- | ----------------------- |
|
||||
| `entry_id` | Config entry ID (optional — auto-selects if only one home) | Auto |
|
||||
| `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` |
|
||||
| `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:**
|
||||
|
||||
Omit the `day` parameter to get a dynamic 48-hour rolling window that automatically adapts to data availability:
|
||||
|
||||
<details>
|
||||
<summary>Show YAML: Rolling Window Mode</summary>
|
||||
|
||||
```yaml
|
||||
service: tibber_prices.get_chartdata
|
||||
data:
|
||||
entry_id: YOUR_CONFIG_ENTRY_ID
|
||||
# Omit 'day' for rolling window
|
||||
output_format: array_of_objects
|
||||
response_variable: chart_data
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
**Behavior:**
|
||||
- **When tomorrow data available** (typically after ~13:00): Returns today + tomorrow
|
||||
- **When tomorrow data not available**: Returns yesterday + today
|
||||
|
||||
This is useful for charts that should always show a 48-hour window without manual day selection.
|
||||
|
||||
**Period Filter Example:**
|
||||
|
||||
Get best price periods as summaries instead of intervals:
|
||||
|
||||
<details>
|
||||
<summary>Show YAML: Period Filter</summary>
|
||||
|
||||
```yaml
|
||||
service: tibber_prices.get_chartdata
|
||||
data:
|
||||
entry_id: YOUR_CONFIG_ENTRY_ID
|
||||
period_filter: best_price # or peak_price
|
||||
day: ["today", "tomorrow"]
|
||||
include_level: true
|
||||
include_rating_level: true
|
||||
response_variable: periods
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
**Advanced Filtering:**
|
||||
|
||||
<details>
|
||||
<summary>Show YAML: Advanced Filtering</summary>
|
||||
|
||||
```yaml
|
||||
service: tibber_prices.get_chartdata
|
||||
data:
|
||||
entry_id: YOUR_CONFIG_ENTRY_ID
|
||||
level_filter: ["VERY_CHEAP", "CHEAP"] # Only cheap periods
|
||||
rating_level_filter: ["LOW"] # Only low-rated prices
|
||||
insert_nulls: segments # Add nulls at segment boundaries
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
**Complete Documentation:**
|
||||
|
||||
For detailed parameter descriptions, open **Developer Tools → Actions** and select `tibber_prices.get_chartdata`. The inline documentation is stored in `services.yaml` because actions are backed by services.
|
||||
|
||||
### Energy & Tax Fields
|
||||
|
||||
You can include the raw energy price (spot price) and/or tax component in chart data output. This is useful for visualizing how the total price is composed over time, or for feed-in calculations.
|
||||
|
||||
| Parameter | Description | Default |
|
||||
|-----------|-------------|---------|
|
||||
| `include_energy` | Include raw energy/spot price per data point | `false` |
|
||||
| `include_tax` | Include tax/fees component per data point | `false` |
|
||||
| `energy_field` | Custom field name for energy price | `energy_price` |
|
||||
| `tax_field` | Custom field name for tax | `tax` |
|
||||
|
||||
**Example: Chart with price composition**
|
||||
|
||||
<details>
|
||||
<summary>Show YAML: Energy and Tax Fields</summary>
|
||||
|
||||
```yaml
|
||||
service: tibber_prices.get_chartdata
|
||||
data:
|
||||
entry_id: YOUR_CONFIG_ENTRY_ID
|
||||
day: ["today", "tomorrow"]
|
||||
include_energy: true
|
||||
include_tax: true
|
||||
response_variable: chart_data
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
Returns data points like:
|
||||
|
||||
<details>
|
||||
<summary>Show JSON: Returns data points like</summary>
|
||||
|
||||
```json
|
||||
{
|
||||
"start_time": "2025-11-17T14:00:00+01:00",
|
||||
"price_per_kwh": 25.34,
|
||||
"energy_price": 12.18,
|
||||
"tax": 13.16
|
||||
}
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
**Use case — Solar feed-in chart:** Overlay the energy price (what you earn by exporting) alongside the total price to visualize the best export windows. See [Energy & Tax Attributes](sensors-energy-tax.md) for more use cases.
|
||||
|
||||
---
|
||||
|
||||
## tibber_prices.get_apexcharts_yaml
|
||||
|
||||
> ⚠️ **IMPORTANT:** This action generates a **basic example configuration** as a starting point, NOT a complete solution for all ApexCharts features.
|
||||
>
|
||||
> This integration is primarily a **data provider**. The generated YAML demonstrates how to use the `get_chartdata` action to fetch price data. Due to the segmented nature of our data (different time periods per series) and the use of Home Assistant's service API instead of entity attributes, many advanced ApexCharts features (like `in_header`, certain transformations) are **not compatible** or require manual customization.
|
||||
>
|
||||
> **You are welcome to customize** the generated YAML for your specific needs, but comprehensive ApexCharts configuration support is beyond the scope of this integration. Community contributions with improved configurations are always appreciated!
|
||||
>
|
||||
> **For custom solutions:** Use the `get_chartdata` action directly to build your own charts with full control over the data format and visualization.
|
||||
|
||||
**Purpose:** Generates a basic ApexCharts card YAML configuration example for visualizing electricity prices with automatic color-coding by price level.
|
||||
|
||||
**Prerequisites:**
|
||||
- [ApexCharts Card](https://github.com/RomRider/apexcharts-card) (required for all configurations)
|
||||
- [Config Template Card](https://github.com/iantrich/config-template-card) (required only for rolling window modes - enables dynamic Y-axis scaling)
|
||||
|
||||
**Key Features:**
|
||||
|
||||
- **Automatic Color-Coded Series**: Separate series for each price level (VERY_CHEAP, CHEAP, NORMAL, EXPENSIVE, VERY_EXPENSIVE) or rating (LOW, NORMAL, HIGH)
|
||||
- **Dynamic Y-Axis Scaling**: Rolling window modes automatically use `chart_metadata` sensor for optimal Y-axis bounds
|
||||
- **Best Price Period Highlights**: Optional vertical bands showing detected best price periods
|
||||
- **Translated Labels**: Automatically uses your Home Assistant language setting
|
||||
- **Clean Gap Visualization**: Proper NULL insertion for missing data segments
|
||||
|
||||
**Quick Example:**
|
||||
|
||||
<details>
|
||||
<summary>Show YAML: Quick Example</summary>
|
||||
|
||||
```yaml
|
||||
service: tibber_prices.get_apexcharts_yaml
|
||||
data:
|
||||
entry_id: YOUR_CONFIG_ENTRY_ID
|
||||
day: today # Optional: yesterday, today, tomorrow, rolling_window, rolling_window_autozoom
|
||||
level_type: rating_level # or "level" for 5-level classification
|
||||
highlight_best_price: true # Show best price period overlays
|
||||
response_variable: apexcharts_config
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
**Day Parameter Options:**
|
||||
|
||||
- **Fixed days** (`yesterday`, `today`, `tomorrow`): Static 24-hour views, no additional dependencies
|
||||
- **Rolling Window** (default when omitted or `rolling_window`): Dynamic 48-hour window that automatically shifts between yesterday+today and today+tomorrow based on data availability
|
||||
- **Includes dynamic Y-axis scaling** via `chart_metadata` sensor
|
||||
- **Rolling Window (Auto-Zoom)** (`rolling_window_autozoom`): Same as rolling window, but additionally zooms in progressively (2h lookback + remaining time until midnight, graph span decreases every 15 minutes)
|
||||
- **Includes dynamic Y-axis scaling** via `chart_metadata` sensor
|
||||
|
||||
**Dynamic Y-Axis Scaling (Rolling Window Modes):**
|
||||
|
||||
Rolling window configurations automatically integrate with the `chart_metadata` sensor for optimal chart appearance:
|
||||
|
||||
- **Automatic bounds**: Y-axis min/max adjust to data range
|
||||
- **No manual configuration**: Works out of the box if sensor is enabled
|
||||
- **Fallback behavior**: If sensor is disabled, uses ApexCharts auto-scaling
|
||||
- **Real-time updates**: Y-axis adapts when price data changes
|
||||
|
||||
**Example: Today's Prices (Static View)**
|
||||
|
||||
<details>
|
||||
<summary>Show YAML: Today Static View</summary>
|
||||
|
||||
```yaml
|
||||
service: tibber_prices.get_apexcharts_yaml
|
||||
data:
|
||||
entry_id: YOUR_CONFIG_ENTRY_ID
|
||||
day: today
|
||||
level_type: rating_level
|
||||
response_variable: config
|
||||
|
||||
# Use in dashboard:
|
||||
type: custom:apexcharts-card
|
||||
# ... paste generated config
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
**Example: Rolling 48h Window (Dynamic View)**
|
||||
|
||||
<details>
|
||||
<summary>Show YAML: Rolling 48h Dynamic View</summary>
|
||||
|
||||
```yaml
|
||||
service: tibber_prices.get_apexcharts_yaml
|
||||
data:
|
||||
entry_id: YOUR_CONFIG_ENTRY_ID
|
||||
# Omit 'day' for rolling window (or use 'rolling_window')
|
||||
level_type: level # 5-level classification
|
||||
highlight_best_price: true
|
||||
response_variable: config
|
||||
|
||||
# Use in dashboard:
|
||||
type: custom:config-template-card
|
||||
entities:
|
||||
- binary_sensor.<home_name>_tomorrow_s_data_available
|
||||
- sensor.<home_name>_chart_metadata # For dynamic Y-axis
|
||||
card:
|
||||
# ... paste generated config
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
**Level Type Options:**
|
||||
|
||||
- **`rating_level`** (default): 3 series (LOW, NORMAL, HIGH) - based on your personal thresholds
|
||||
- **`level`**: 5 series (VERY_CHEAP, CHEAP, NORMAL, EXPENSIVE, VERY_EXPENSIVE) - absolute price ranges
|
||||
|
||||
**Best Price Period Highlights:**
|
||||
|
||||
When `highlight_best_price: true`:
|
||||
- Vertical bands overlay the chart showing detected best price periods
|
||||
- Tooltip shows "Best Price Period" label when hovering over highlighted areas
|
||||
- Only appears when best price periods are configured and detected
|
||||
|
||||
**Important Notes:**
|
||||
|
||||
- **Config Template Card** is only required for rolling window modes (enables dynamic Y-axis)
|
||||
- Fixed day views (`today`, `tomorrow`, `yesterday`) work with ApexCharts Card alone
|
||||
- Generated YAML is a starting point - customize colors, styling, features as needed
|
||||
- All labels are automatically translated to your Home Assistant language
|
||||
|
||||
Use the response in Lovelace dashboards by copying the generated YAML.
|
||||
|
||||
**Documentation:** Refer to **Developer Tools → Actions** for descriptions of the fields exposed by this action.
|
||||
|
||||
---
|
||||
|
||||
## Migration from Chart Data Export Sensor
|
||||
|
||||
If you're still using the `sensor.<home_name>_chart_data_export` sensor, consider migrating to the `tibber_prices.get_chartdata` action:
|
||||
|
||||
**Benefits:**
|
||||
|
||||
- No HA restart required for configuration changes
|
||||
- More flexible filtering and formatting options
|
||||
- Better performance (on-demand instead of polling)
|
||||
- Future-proof (active development)
|
||||
|
||||
**Migration Steps:**
|
||||
|
||||
1. Note your current sensor configuration (Step 7 in Options Flow)
|
||||
2. Create automation/script that calls `tibber_prices.get_chartdata` with the same parameters
|
||||
3. Test the new approach
|
||||
4. Disable the old sensor when satisfied
|
||||
|
|
@ -4,7 +4,9 @@ This guide showcases the different chart configurations available through the `t
|
|||
|
||||
> **Quick Start:** Call the action with your desired parameters, copy the generated YAML, and paste it into your Lovelace dashboard!
|
||||
|
||||
> **Entity ID tip:** `<home_name>` is a placeholder for your Tibber home display name in Home Assistant. Entity IDs are derived from the displayed name (localized), so the exact slug may differ. **Can't find a sensor?** Use the **[Entity Reference (All Languages)](sensor-reference.md)** to search by name in your language.
|
||||
:::tip Entity ID tip
|
||||
`<home_name>` is a placeholder for your Tibber home display name in Home Assistant. Entity IDs are derived from the displayed name (localized), so the exact slug may differ. **Can't find a sensor?** Use the **[Entity Reference (All Languages)](sensor-reference.md)** to search by name in your language.
|
||||
:::
|
||||
|
||||
:::info Finding your Entry ID (`entry_id`)
|
||||
Every example below contains `entry_id: YOUR_CONFIG_ENTRY_ID`. This value identifies which Tibber home (integration instance) the action targets.
|
||||
|
|
@ -39,6 +41,9 @@ The integration can generate 4 different chart modes, each optimized for specifi
|
|||
**Dependencies:** ApexCharts Card only
|
||||
|
||||
**Generate:**
|
||||
<details>
|
||||
<summary>Show YAML: Today's Prices (Static)</summary>
|
||||
|
||||
```yaml
|
||||
service: tibber_prices.get_apexcharts_yaml
|
||||
data:
|
||||
|
|
@ -48,6 +53,8 @@ data:
|
|||
highlight_best_price: true
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
**Screenshot:**
|
||||
|
||||

|
||||
|
|
@ -69,6 +76,9 @@ data:
|
|||
**Dependencies:** ApexCharts Card + Config Template Card
|
||||
|
||||
**Generate:**
|
||||
<details>
|
||||
<summary>Show YAML: Rolling 48h Window</summary>
|
||||
|
||||
```yaml
|
||||
service: tibber_prices.get_apexcharts_yaml
|
||||
data:
|
||||
|
|
@ -78,6 +88,8 @@ data:
|
|||
highlight_best_price: true
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
**Screenshot:**
|
||||
|
||||

|
||||
|
|
@ -103,6 +115,9 @@ data:
|
|||
**Dependencies:** ApexCharts Card + Config Template Card
|
||||
|
||||
**Generate:**
|
||||
<details>
|
||||
<summary>Show YAML: Rolling Auto-Zoom</summary>
|
||||
|
||||
```yaml
|
||||
service: tibber_prices.get_apexcharts_yaml
|
||||
data:
|
||||
|
|
@ -112,6 +127,8 @@ data:
|
|||
highlight_best_price: true
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
**Screenshot:**
|
||||
|
||||

|
||||
|
|
@ -165,6 +182,9 @@ Based on **absolute price ranges** (calculated from daily min/max):
|
|||
Rolling window modes (2 & 3) automatically integrate with the `chart_metadata` sensor for optimal visualization:
|
||||
|
||||
**Without chart_metadata sensor (disabled):**
|
||||
<details>
|
||||
<summary>Show chart illustration: Without Chart Metadata</summary>
|
||||
|
||||
```
|
||||
┌─────────────────────┐
|
||||
│ │ ← Lots of empty space
|
||||
|
|
@ -175,7 +195,12 @@ Rolling window modes (2 & 3) automatically integrate with the `chart_metadata` s
|
|||
0 100 ct
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
**With chart_metadata sensor (enabled):**
|
||||
<details>
|
||||
<summary>Show chart illustration: With Chart Metadata</summary>
|
||||
|
||||
```
|
||||
┌─────────────────────┐
|
||||
│ ___ │ ← Y-axis fitted to data
|
||||
|
|
@ -185,6 +210,8 @@ Rolling window modes (2 & 3) automatically integrate with the `chart_metadata` s
|
|||
18 28 ct ← Optimal range
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
**Requirements:**
|
||||
|
||||
- ✅ The `sensor.<home_name>_chart_metadata` must be **enabled** (it's enabled by default!)
|
||||
|
|
@ -201,6 +228,9 @@ Rolling window modes (2 & 3) automatically integrate with the `chart_metadata` s
|
|||
When `highlight_best_price: true`, vertical bands overlay the chart showing detected best price periods:
|
||||
|
||||
**Example:**
|
||||
<details>
|
||||
<summary>Show chart illustration: Best Price Period Highlights</summary>
|
||||
|
||||
```
|
||||
Price
|
||||
│
|
||||
|
|
@ -214,6 +244,8 @@ Price
|
|||
06:00 12:00 18:00
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
**Features:**
|
||||
- Automatic detection based on your configuration (see [Period Calculation Guide](period-calculation.md))
|
||||
- Tooltip shows "Best Price Period" label
|
||||
|
|
@ -227,19 +259,29 @@ Price
|
|||
### Required for All Modes
|
||||
|
||||
- **[ApexCharts Card](https://github.com/RomRider/apexcharts-card)**: Core visualization library
|
||||
<details>
|
||||
<summary>Show shell command: Prerequisite for All Modes</summary>
|
||||
|
||||
```bash
|
||||
# Install via HACS
|
||||
HACS → Frontend → Search "ApexCharts Card" → Download
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
### Required for Rolling Window Modes Only
|
||||
|
||||
- **[Config Template Card](https://github.com/iantrich/config-template-card)**: Enables dynamic configuration
|
||||
<details>
|
||||
<summary>Show shell command: Rolling Window Prerequisite</summary>
|
||||
|
||||
```bash
|
||||
# Install via HACS
|
||||
HACS → Frontend → Search "Config Template Card" → Download
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
**Note:** Fixed day views (`today`, `tomorrow`) work with ApexCharts Card alone!
|
||||
|
||||
---
|
||||
|
|
@ -250,6 +292,9 @@ Price
|
|||
|
||||
Edit the `colors` array in the generated YAML:
|
||||
|
||||
<details>
|
||||
<summary>Show YAML: Custom Color Palette</summary>
|
||||
|
||||
```yaml
|
||||
apex_config:
|
||||
colors:
|
||||
|
|
@ -258,10 +303,15 @@ apex_config:
|
|||
- "#FF0000" # Change HIGH/VERY_EXPENSIVE color
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
### Changing Chart Height
|
||||
|
||||
Add to the card configuration:
|
||||
|
||||
<details>
|
||||
<summary>Show YAML: Custom Chart Height</summary>
|
||||
|
||||
```yaml
|
||||
type: custom:apexcharts-card
|
||||
graph_span: 48h
|
||||
|
|
@ -273,10 +323,15 @@ apex_config:
|
|||
height: 400 # Adjust height in pixels
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
### Combining with Other Cards
|
||||
|
||||
Wrap in a vertical stack for dashboard integration:
|
||||
|
||||
<details>
|
||||
<summary>Show YAML: Vertical Stack Integration</summary>
|
||||
|
||||
```yaml
|
||||
type: vertical-stack
|
||||
cards:
|
||||
|
|
@ -286,11 +341,13 @@ cards:
|
|||
# ... generated chart config
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## Next Steps
|
||||
|
||||
- **[Actions Guide](actions.md)**: Complete documentation of `get_apexcharts_yaml` parameters
|
||||
- **[Chart Actions Guide](chart-actions.md)**: Complete documentation of `get_apexcharts_yaml` parameters
|
||||
- **[Chart Metadata Sensor](sensors-overview.md#chart-metadata)**: Learn about dynamic Y-axis scaling
|
||||
- **[Period Calculation Guide](period-calculation.md)**: Configure best price period detection
|
||||
|
||||
|
|
|
|||
|
|
@ -62,7 +62,7 @@ Create `input_number` helpers in Home Assistant for each fee component. This way
|
|||
| Verkoopvergoeding | `input_number.verkoopvergoeding` | 0 | 1 | 0.0001 | €/kWh | 0.0205 |
|
||||
|
||||
<details>
|
||||
<summary>Alternative: YAML configuration for input_number helpers</summary>
|
||||
<summary>Show YAML: Input Number Helpers</summary>
|
||||
|
||||
If you prefer YAML configuration over the UI, add these to your `configuration.yaml`:
|
||||
|
||||
|
|
@ -105,7 +105,7 @@ input_number:
|
|||
These template sensors calculate what you **earn** per kWh when feeding solar power back to the grid.
|
||||
|
||||
<details>
|
||||
<summary>Show YAML: Template sensors for feed-in with and without saldering</summary>
|
||||
<summary>Show YAML: Feed-In Compensation Sensors</summary>
|
||||
|
||||
```yaml
|
||||
template:
|
||||
|
|
@ -156,7 +156,7 @@ template:
|
|||
Now you can use these sensors to make smarter decisions about when to export solar power vs. charge a battery:
|
||||
|
||||
<details>
|
||||
<summary>Show YAML: Smart export automation</summary>
|
||||
<summary>Show YAML: Feed-In Automation</summary>
|
||||
|
||||
```yaml
|
||||
automation:
|
||||
|
|
@ -188,7 +188,7 @@ automation:
|
|||
To understand the financial impact of the saldering phase-out, you can create a dashboard comparing both scenarios side by side:
|
||||
|
||||
<details>
|
||||
<summary>Show YAML: Dashboard comparison card</summary>
|
||||
<summary>Show YAML: Preparing for the End of Saldering</summary>
|
||||
|
||||
```yaml
|
||||
type: entities
|
||||
|
|
@ -233,7 +233,7 @@ In Germany, the electricity price includes numerous components bundled into `tax
|
|||
A simple template sensor showing what percentage of your total price is the actual energy cost:
|
||||
|
||||
<details>
|
||||
<summary>Show YAML: Spot price share template sensor</summary>
|
||||
<summary>Show YAML: Spot Price Share</summary>
|
||||
|
||||
```yaml
|
||||
template:
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
# Configuration
|
||||
|
||||
> **Entity ID tip:** `<home_name>` is a placeholder for your Tibber home display name in Home Assistant. Entity IDs are derived from the displayed name (localized), so the exact slug may differ. **Can't find a sensor?** Use the **[Entity Reference (All Languages)](sensor-reference.md)** to search by name in your language.
|
||||
:::tip Entity ID tip
|
||||
`<home_name>` is a placeholder for your Tibber home display name in Home Assistant. Entity IDs are derived from the displayed name (localized), so the exact slug may differ. **Can't find a sensor?** Use the **[Entity Reference (All Languages)](sensor-reference.md)** to search by name in your language.
|
||||
:::
|
||||
|
||||
## Initial Setup
|
||||
|
||||
|
|
@ -125,7 +127,7 @@ Thresholds are [volatility-adaptive](sensors-trends.md): automatically widened o
|
|||
|
||||
### Step 9: Chart Data Export (Legacy)
|
||||
|
||||
Information page for the legacy chart data export sensor. For new setups, use the [get_chartdata action](actions.md) instead.
|
||||
Information page for the legacy chart data export sensor. For new setups, use the [get_chartdata action](chart-actions.md) instead.
|
||||
|
||||
## Configuration Options
|
||||
|
||||
|
|
@ -152,10 +154,15 @@ The integration allows you to choose how average price sensors display their val
|
|||
#### Why This Matters
|
||||
|
||||
Consider a day with these hourly prices:
|
||||
<details>
|
||||
<summary>Show example: Mean vs Median</summary>
|
||||
|
||||
```
|
||||
10, 12, 13, 15, 80 ct/kWh
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
- **Median = 13 ct/kWh** ← "Typical" price (middle value, ignores spike)
|
||||
- **Mean = 26 ct/kWh** ← Average cost (spike pulls it up)
|
||||
|
||||
|
|
@ -165,12 +172,17 @@ The median tells you the price was **typically** around 13 ct/kWh (4 out of 5 ho
|
|||
|
||||
**Both values are always available as attributes**, regardless of your display choice:
|
||||
|
||||
<details>
|
||||
<summary>Show YAML: Median and Mean Attributes</summary>
|
||||
|
||||
```yaml
|
||||
# These attributes work regardless of display setting:
|
||||
{{ state_attr('sensor.<home_name>_price_today', 'price_median') }}
|
||||
{{ state_attr('sensor.<home_name>_price_today', 'price_mean') }}
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
This means:
|
||||
- ✅ You can change the display anytime without breaking automations
|
||||
- ✅ Automations can use both values for different purposes
|
||||
|
|
@ -255,6 +267,9 @@ Each configuration entity includes a detailed description attribute explaining w
|
|||
|
||||
### Example: Seasonal Automation
|
||||
|
||||
<details>
|
||||
<summary>Show YAML: Seasonal Runtime Override</summary>
|
||||
|
||||
```yaml
|
||||
automation:
|
||||
- alias: "Winter: Stricter Best Price Detection"
|
||||
|
|
@ -272,6 +287,8 @@ automation:
|
|||
value: 10 # Stricter than default 15%
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
### Recorder Optimization (Optional)
|
||||
|
||||
These configuration entities are designed to minimize database impact:
|
||||
|
|
@ -283,6 +300,9 @@ If you frequently adjust these settings via automations or want to track configu
|
|||
|
||||
However, if you prefer to **completely exclude** these entities from the recorder (no history graph, no database entries), add this to your `configuration.yaml`:
|
||||
|
||||
<details>
|
||||
<summary>Show YAML: Exclude Runtime Config Entities</summary>
|
||||
|
||||
```yaml
|
||||
recorder:
|
||||
exclude:
|
||||
|
|
@ -294,6 +314,8 @@ recorder:
|
|||
- switch.*_peak_price_*
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
This is especially useful if:
|
||||
- You rarely change these settings
|
||||
- You want the smallest possible database footprint
|
||||
|
|
|
|||
|
|
@ -2,12 +2,17 @@
|
|||
|
||||
Beautiful dashboard layouts using Tibber Prices sensors.
|
||||
|
||||
> **Entity ID tip:** `<home_name>` is a placeholder for your Tibber home display name in Home Assistant. Entity IDs are derived from the displayed name (localized), so the exact slug may differ. **Can't find a sensor?** Use the **[Entity Reference (All Languages)](sensor-reference.md)** to search by name in your language.
|
||||
:::tip Entity ID tip
|
||||
`<home_name>` is a placeholder for your Tibber home display name in Home Assistant. Entity IDs are derived from the displayed name (localized), so the exact slug may differ. **Can't find a sensor?** Use the **[Entity Reference (All Languages)](sensor-reference.md)** to search by name in your language.
|
||||
:::
|
||||
|
||||
## Basic Price Display Card
|
||||
|
||||
Simple card showing current price with dynamic color:
|
||||
|
||||
<details>
|
||||
<summary>Show YAML: Current Price Card</summary>
|
||||
|
||||
```yaml
|
||||
type: entities
|
||||
title: Current Electricity Price
|
||||
|
|
@ -21,10 +26,15 @@ entities:
|
|||
name: Next Price
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
## Period Status Cards
|
||||
|
||||
Show when best/peak price periods are active:
|
||||
|
||||
<details>
|
||||
<summary>Show YAML: Best and Peak Period Card</summary>
|
||||
|
||||
```yaml
|
||||
type: horizontal-stack
|
||||
cards:
|
||||
|
|
@ -38,12 +48,14 @@ cards:
|
|||
icon: mdi:alert
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
## Custom Button Card Examples
|
||||
|
||||
### Price Level Card
|
||||
|
||||
<details>
|
||||
<summary>Show YAML: Price level card with color gradients (custom:button-card)</summary>
|
||||
<summary>Show YAML: Price Level Card</summary>
|
||||
|
||||
```yaml
|
||||
type: custom:button-card
|
||||
|
|
@ -72,7 +84,7 @@ styles:
|
|||
Optimized for mobile devices:
|
||||
|
||||
<details>
|
||||
<summary>Show YAML: Compact mobile layout</summary>
|
||||
<summary>Show YAML: Optimized for mobile devices</summary>
|
||||
|
||||
```yaml
|
||||
type: vertical-stack
|
||||
|
|
@ -99,7 +111,7 @@ cards:
|
|||
Full-width layout for desktop:
|
||||
|
||||
<details>
|
||||
<summary>Show YAML: Desktop 3-column grid layout</summary>
|
||||
<summary>Show YAML: Full-width layout for desktop</summary>
|
||||
|
||||
```yaml
|
||||
type: grid
|
||||
|
|
@ -134,7 +146,7 @@ cards:
|
|||
Using the `icon_color` attribute for dynamic colors:
|
||||
|
||||
<details>
|
||||
<summary>Show YAML: Dynamic icon colors with mushroom chips</summary>
|
||||
<summary>Show YAML: Icon Color Integration</summary>
|
||||
|
||||
```yaml
|
||||
type: custom:mushroom-chips-card
|
||||
|
|
@ -161,7 +173,7 @@ See [Icon Colors](icon-colors.md) for detailed color mapping.
|
|||
Advanced interactive dashboard:
|
||||
|
||||
<details>
|
||||
<summary>Show YAML: Picture elements interactive dashboard</summary>
|
||||
<summary>Show YAML: Advanced interactive dashboard</summary>
|
||||
|
||||
```yaml
|
||||
type: picture-elements
|
||||
|
|
@ -191,7 +203,7 @@ elements:
|
|||
Automatically list all price sensors:
|
||||
|
||||
<details>
|
||||
<summary>Show YAML: Auto-entities card for all price sensors</summary>
|
||||
<summary>Show YAML: Auto-Entities Sensor List</summary>
|
||||
|
||||
```yaml
|
||||
type: custom:auto-entities
|
||||
|
|
|
|||
85
docs/user/docs/data-actions.md
Normal file
85
docs/user/docs/data-actions.md
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
# Data & Utility Actions
|
||||
|
||||
Actions for fetching raw price data and managing integration state.
|
||||
|
||||
---
|
||||
|
||||
## tibber_prices.get_price
|
||||
|
||||
**Purpose:** Fetches raw price interval data for any time range. Uses intelligent caching — only intervals not already cached are fetched from the Tibber API.
|
||||
|
||||
**Parameters:**
|
||||
|
||||
| Parameter | Description | Required |
|
||||
|-----------|-------------|----------|
|
||||
| `entry_id` | Config entry ID | Yes |
|
||||
| `start_time` | Start of the time range | Yes |
|
||||
| `end_time` | End of the time range | Yes |
|
||||
|
||||
**Example:**
|
||||
|
||||
<details>
|
||||
<summary>Show YAML: Get Price</summary>
|
||||
|
||||
```yaml
|
||||
service: tibber_prices.get_price
|
||||
data:
|
||||
entry_id: YOUR_CONFIG_ENTRY_ID
|
||||
start_time: "2025-11-01T00:00:00"
|
||||
end_time: "2025-11-02T00:00:00"
|
||||
response_variable: price_data
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
**Response Format:**
|
||||
|
||||
<details>
|
||||
<summary>Show JSON: Get Price Response</summary>
|
||||
|
||||
```json
|
||||
{
|
||||
"home_id": "abc-123",
|
||||
"start_time": "2025-11-01T00:00:00+01:00",
|
||||
"end_time": "2025-11-02T00:00:00+01:00",
|
||||
"interval_count": 96,
|
||||
"price_info": [
|
||||
{
|
||||
"startsAt": "2025-11-01T00:00:00+01:00",
|
||||
"total": 0.2534,
|
||||
"energy": 0.1218,
|
||||
"tax": 0.1316
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
**Use cases:**
|
||||
- Fetching historical price data for analysis
|
||||
- Comparing prices across arbitrary date ranges
|
||||
- Building custom charts with historical data
|
||||
|
||||
**Note:** Times are automatically converted to your Tibber home's timezone. The interval pool caches previously fetched intervals, so repeated calls for the same range are fast.
|
||||
|
||||
---
|
||||
|
||||
## tibber_prices.refresh_user_data
|
||||
|
||||
**Purpose:** Forces an immediate refresh of user data (homes, subscriptions) from the Tibber API.
|
||||
|
||||
**Example:**
|
||||
|
||||
<details>
|
||||
<summary>Show YAML: Refresh User Data</summary>
|
||||
|
||||
```yaml
|
||||
service: tibber_prices.refresh_user_data
|
||||
data:
|
||||
entry_id: YOUR_CONFIG_ENTRY_ID
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
**Note:** User data is cached for 24 hours. Trigger this action only when you need immediate updates (e.g., after changing Tibber subscriptions).
|
||||
|
|
@ -2,7 +2,9 @@
|
|||
|
||||
Many sensors in the Tibber Prices integration automatically change their icon based on their current state. This provides instant visual feedback about price levels, trends, and periods without needing to read the actual values.
|
||||
|
||||
> **Entity ID tip:** `<home_name>` is a placeholder for your Tibber home display name in Home Assistant. Entity IDs are derived from the displayed name (localized), so the exact slug may differ. **Can't find a sensor?** Use the **[Entity Reference (All Languages)](sensor-reference.md)** to search by name in your language.
|
||||
:::tip Entity ID tip
|
||||
`<home_name>` is a placeholder for your Tibber home display name in Home Assistant. Entity IDs are derived from the displayed name (localized), so the exact slug may differ. **Can't find a sensor?** Use the **[Entity Reference (All Languages)](sensor-reference.md)** to search by name in your language.
|
||||
:::
|
||||
|
||||
## What are Dynamic Icons?
|
||||
|
||||
|
|
@ -37,6 +39,9 @@ To see which icon a sensor currently uses:
|
|||
|
||||
Dynamic icons work automatically in standard Home Assistant cards:
|
||||
|
||||
<details>
|
||||
<summary>Show YAML: Standard Entity Cards</summary>
|
||||
|
||||
```yaml
|
||||
type: entities
|
||||
entities:
|
||||
|
|
@ -46,10 +51,15 @@ entities:
|
|||
- entity: binary_sensor.<home_name>_best_price_period
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
The icons will update automatically as the sensor states change.
|
||||
|
||||
### Glance Card
|
||||
|
||||
<details>
|
||||
<summary>Show YAML: Glance Card</summary>
|
||||
|
||||
```yaml
|
||||
type: glance
|
||||
entities:
|
||||
|
|
@ -61,8 +71,13 @@ entities:
|
|||
name: Best Price
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
### Custom Button Card
|
||||
|
||||
<details>
|
||||
<summary>Show YAML: Custom Button Card</summary>
|
||||
|
||||
```yaml
|
||||
type: custom:button-card
|
||||
entity: sensor.<home_name>_current_price_level
|
||||
|
|
@ -71,8 +86,13 @@ show_state: true
|
|||
# Icon updates automatically - no need to specify it!
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
### Mushroom Entity Card
|
||||
|
||||
<details>
|
||||
<summary>Show YAML: Mushroom Entity Card</summary>
|
||||
|
||||
```yaml
|
||||
type: custom:mushroom-entity-card
|
||||
entity: sensor.<home_name>_today_s_price_volatility
|
||||
|
|
@ -80,12 +100,17 @@ name: Price Volatility
|
|||
# Icon changes automatically based on volatility level
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
## Overriding Dynamic Icons
|
||||
|
||||
If you want to use a fixed icon instead of the dynamic one:
|
||||
|
||||
### In Entity Cards
|
||||
|
||||
<details>
|
||||
<summary>Show YAML: Fixed Icon in Entity Card</summary>
|
||||
|
||||
```yaml
|
||||
type: entities
|
||||
entities:
|
||||
|
|
@ -93,8 +118,13 @@ entities:
|
|||
icon: mdi:lightning-bolt # Fixed icon, won't change
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
### In Custom Button Card
|
||||
|
||||
<details>
|
||||
<summary>Show YAML: Fixed Icon in Button Card</summary>
|
||||
|
||||
```yaml
|
||||
type: custom:button-card
|
||||
entity: sensor.<home_name>_current_price_rating
|
||||
|
|
@ -103,12 +133,17 @@ icon: mdi:chart-line # Fixed icon overrides dynamic behavior
|
|||
show_state: true
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
## Combining with Dynamic Colors
|
||||
|
||||
Dynamic icons work great together with dynamic colors! See the **[Dynamic Icon Colors Guide](icon-colors.md)** for examples.
|
||||
|
||||
**Example: Dynamic icon AND color**
|
||||
|
||||
<details>
|
||||
<summary>Show YAML: Dynamic Icon and Color</summary>
|
||||
|
||||
```yaml
|
||||
type: custom:button-card
|
||||
entity: sensor.<home_name>_current_price_level
|
||||
|
|
@ -123,6 +158,8 @@ styles:
|
|||
]]]
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
This gives you both:
|
||||
|
||||
- ✅ Different icon based on state (e.g., cash-plus when cheap, cash-remove when expensive)
|
||||
|
|
|
|||
|
|
@ -112,10 +112,15 @@ If you see unexpected units, check your configuration in the integration options
|
|||
|
||||
## Automation Questions
|
||||
|
||||
> **Entity ID tip:** `<home_name>` is a placeholder for your Tibber home display name in Home Assistant. Entity IDs are derived from the displayed name (localized), so the exact slug may differ. **Can't find a sensor?** Use the **[Entity Reference (All Languages)](sensor-reference.md)** to search by name in your language.
|
||||
:::tip Entity ID tip
|
||||
`<home_name>` is a placeholder for your Tibber home display name in Home Assistant. Entity IDs are derived from the displayed name (localized), so the exact slug may differ. **Can't find a sensor?** Use the **[Entity Reference (All Languages)](sensor-reference.md)** to search by name in your language.
|
||||
:::
|
||||
|
||||
### How do I run dishwasher during cheap period?
|
||||
|
||||
<details>
|
||||
<summary>Show YAML: Dishwasher During Cheap Period</summary>
|
||||
|
||||
```yaml
|
||||
automation:
|
||||
- alias: "Dishwasher during Best Price"
|
||||
|
|
@ -132,12 +137,17 @@ automation:
|
|||
entity_id: switch.dishwasher
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
See [Automation Examples](automation-examples.md) for more recipes.
|
||||
|
||||
### Can I avoid peak prices automatically?
|
||||
|
||||
Yes! Use Peak Price Period binary sensor:
|
||||
|
||||
<details>
|
||||
<summary>Show YAML: Avoid Peak Prices Automatically</summary>
|
||||
|
||||
```yaml
|
||||
automation:
|
||||
- alias: "Disable charging during peak prices"
|
||||
|
|
@ -151,6 +161,8 @@ automation:
|
|||
entity_id: switch.ev_charger
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
💡 **Still need help?**
|
||||
|
|
|
|||
|
|
@ -10,7 +10,9 @@ Many sensors in the Tibber Prices integration provide an `icon_color` attribute
|
|||
|
||||
> **Related:** Many sensors also automatically change their **icon** based on state. See the **[Dynamic Icons Guide](dynamic-icons.md)** for details.
|
||||
|
||||
> **Entity ID tip:** `<home_name>` is a placeholder for your Tibber home display name in Home Assistant. Entity IDs are derived from the displayed name (localized), so the exact slug may differ. **Can't find a sensor?** Use the **[Entity Reference (All Languages)](sensor-reference.md)** to search by name in your language.
|
||||
:::tip Entity ID tip
|
||||
`<home_name>` is a placeholder for your Tibber home display name in Home Assistant. Entity IDs are derived from the displayed name (localized), so the exact slug may differ. **Can't find a sensor?** Use the **[Entity Reference (All Languages)](sensor-reference.md)** to search by name in your language.
|
||||
:::
|
||||
|
||||
## What is icon_color?
|
||||
|
||||
|
|
@ -65,6 +67,9 @@ The colors adapt to the sensor's state - cheaper prices typically show green, ex
|
|||
|
||||
**Example of when NOT to use icon_color:**
|
||||
|
||||
<details>
|
||||
<summary>Show YAML: Conversion vs Direct State Logic</summary>
|
||||
|
||||
```yaml
|
||||
# ❌ DON'T: Converting icon_color requires if/else anyway
|
||||
card:
|
||||
|
|
@ -87,6 +92,8 @@ card:
|
|||
]]]
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
The advantage of `icon_color` is simplicity - if you need complex logic, you lose that advantage.
|
||||
|
||||
## How to Use icon_color in Your Dashboard
|
||||
|
|
@ -97,6 +104,9 @@ The [custom:button-card](https://github.com/custom-cards/button-card) from HACS
|
|||
|
||||
**Example: Icon color only**
|
||||
|
||||
<details>
|
||||
<summary>Show YAML: Icon color only</summary>
|
||||
|
||||
```yaml
|
||||
type: custom:button-card
|
||||
entity: sensor.<home_name>_current_price_level
|
||||
|
|
@ -111,8 +121,13 @@ styles:
|
|||
]]]
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
**Example: Icon AND state value with same color**
|
||||
|
||||
<details>
|
||||
<summary>Show YAML: Button Card Icon and State Text</summary>
|
||||
|
||||
```yaml
|
||||
type: custom:button-card
|
||||
entity: sensor.<home_name>_current_price_level
|
||||
|
|
@ -133,12 +148,14 @@ styles:
|
|||
- font-weight: bold
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
### Method 2: Entities Card with card_mod
|
||||
|
||||
Use Home Assistant's built-in entities card with card_mod for icon and state colors:
|
||||
|
||||
<details>
|
||||
<summary>Show YAML: Entities card with card_mod</summary>
|
||||
<summary>Show YAML: Entities Card with card-mod</summary>
|
||||
|
||||
```yaml
|
||||
type: entities
|
||||
|
|
@ -164,6 +181,9 @@ The [Mushroom cards](https://github.com/piitaya/lovelace-mushroom) support card_
|
|||
|
||||
**Icon color only:**
|
||||
|
||||
<details>
|
||||
<summary>Show YAML: Mushroom Icon Color Only</summary>
|
||||
|
||||
```yaml
|
||||
type: custom:mushroom-entity-card
|
||||
entity: binary_sensor.<home_name>_best_price_period
|
||||
|
|
@ -176,10 +196,12 @@ card_mod:
|
|||
}
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
**Icon and state value:**
|
||||
|
||||
<details>
|
||||
<summary>Show YAML: Mushroom card — icon and state value with same color</summary>
|
||||
<summary>Show YAML: Icon and state value</summary>
|
||||
|
||||
```yaml
|
||||
type: custom:mushroom-entity-card
|
||||
|
|
@ -200,7 +222,7 @@ card_mod:
|
|||
Combine multiple sensors with dynamic colors:
|
||||
|
||||
<details>
|
||||
<summary>Show YAML: Glance card with card_mod for multiple sensors</summary>
|
||||
<summary>Show YAML: Multi-Sensor Dynamic Colors</summary>
|
||||
|
||||
```yaml
|
||||
type: glance
|
||||
|
|
@ -228,7 +250,7 @@ card_mod:
|
|||
Here's a complete example combining multiple sensors with dynamic colors:
|
||||
|
||||
<details>
|
||||
<summary>Show YAML: Complete dashboard with dynamic colors for all sensor types</summary>
|
||||
<summary>Show YAML: Complete Dashboard Example</summary>
|
||||
|
||||
```yaml
|
||||
type: vertical-stack
|
||||
|
|
@ -334,6 +356,9 @@ If you want to override the theme colors with your own, you have two options:
|
|||
|
||||
Define custom colors in your theme configuration (`themes.yaml`):
|
||||
|
||||
<details>
|
||||
<summary>Show YAML: Theme-Level Color Overrides</summary>
|
||||
|
||||
```yaml
|
||||
my_custom_theme:
|
||||
# Override standard variables
|
||||
|
|
@ -343,6 +368,8 @@ my_custom_theme:
|
|||
info-color: "#0288D1" # Custom blue
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
The `icon_color` attribute will automatically use your custom theme colors.
|
||||
|
||||
#### Option 2: Interpret State Value Directly
|
||||
|
|
@ -352,7 +379,7 @@ Instead of using `icon_color`, read the sensor state and apply your own colors:
|
|||
**Example: Custom colors for price level**
|
||||
|
||||
<details>
|
||||
<summary>Show YAML: Price level — 5-color JavaScript mapping</summary>
|
||||
<summary>Show YAML: Custom colors for price level</summary>
|
||||
|
||||
```yaml
|
||||
type: custom:button-card
|
||||
|
|
@ -379,7 +406,7 @@ styles:
|
|||
**Example: Custom colors for binary sensor**
|
||||
|
||||
<details>
|
||||
<summary>Show YAML: Binary sensor — on/off color + background highlight</summary>
|
||||
<summary>Show YAML: Custom colors for binary sensor</summary>
|
||||
|
||||
```yaml
|
||||
type: custom:button-card
|
||||
|
|
@ -406,7 +433,7 @@ styles:
|
|||
**Example: Custom colors for volatility**
|
||||
|
||||
<details>
|
||||
<summary>Show YAML: Volatility sensor — 4-level color mapping</summary>
|
||||
<summary>Show YAML: Custom colors for volatility</summary>
|
||||
|
||||
```yaml
|
||||
type: custom:button-card
|
||||
|
|
@ -431,7 +458,7 @@ styles:
|
|||
**Example: Custom colors for price rating**
|
||||
|
||||
<details>
|
||||
<summary>Show YAML: Price rating — 3-state color mapping</summary>
|
||||
<summary>Show YAML: Custom colors for price rating</summary>
|
||||
|
||||
```yaml
|
||||
type: custom:button-card
|
||||
|
|
|
|||
|
|
@ -16,9 +16,14 @@
|
|||
2. Go to **Integrations**
|
||||
3. Click the **⋮** menu (top right) → **Custom repositories**
|
||||
4. Add the repository URL:
|
||||
<details>
|
||||
<summary>Show example: Repository URL</summary>
|
||||
|
||||
```
|
||||
https://github.com/jpawlowski/hass.tibber_prices
|
||||
```
|
||||
|
||||
</details>
|
||||
Category: **Integration**
|
||||
5. Click **Add**
|
||||
6. Find **Tibber Price Information & Ratings** in the integration list
|
||||
|
|
@ -42,6 +47,9 @@ If you prefer not to use HACS:
|
|||
1. Download the [latest release](https://github.com/jpawlowski/hass.tibber_prices/releases/latest) from GitHub
|
||||
2. Extract the `custom_components/tibber_prices/` folder
|
||||
3. Copy it to your Home Assistant `config/custom_components/` directory:
|
||||
<details>
|
||||
<summary>Show example: Folder Structure</summary>
|
||||
|
||||
```
|
||||
config/
|
||||
└── custom_components/
|
||||
|
|
@ -52,6 +60,8 @@ If you prefer not to use HACS:
|
|||
├── binary_sensor/
|
||||
└── ...
|
||||
```
|
||||
|
||||
</details>
|
||||
4. **Restart Home Assistant**
|
||||
5. Continue with [Configuration](configuration.md)
|
||||
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ This is an independent, community-maintained custom integration. It is **not** a
|
|||
- **[Sensors](sensors-overview.md)** - Available sensors, their states, and attributes
|
||||
- **[Dynamic Icons](dynamic-icons.md)** - State-based automatic icon changes
|
||||
- **[Dynamic Icon Colors](icon-colors.md)** - Using icon_color attribute for color-coded dashboards
|
||||
- **[Actions](actions.md)** - Custom actions (service endpoints) and how to use them
|
||||
- **[Actions](actions.md)** - Scheduling, chart, and data actions for automations and dashboards
|
||||
- **[Chart Examples](chart-examples.md)** - ✨ ApexCharts visualizations with screenshots
|
||||
- **[Automation Examples](automation-examples.md)** - Ready-to-use automation recipes
|
||||
- **[Troubleshooting](troubleshooting.md)** - Common issues and solutions
|
||||
|
|
|
|||
|
|
@ -2,7 +2,9 @@
|
|||
|
||||
Learn how Best Price and Peak Price periods work, and how to configure them for your needs.
|
||||
|
||||
> **Entity ID tip:** `<home_name>` is a placeholder for your Tibber home display name in Home Assistant. Entity IDs are derived from the displayed name (localized), so the exact slug may differ. **Can't find a sensor?** Use the **[Entity Reference (All Languages)](sensor-reference.md)** to search by name in your language.
|
||||
:::tip Entity ID tip
|
||||
`<home_name>` is a placeholder for your Tibber home display name in Home Assistant. Entity IDs are derived from the displayed name (localized), so the exact slug may differ. **Can't find a sensor?** Use the **[Entity Reference (All Languages)](sensor-reference.md)** to search by name in your language.
|
||||
:::
|
||||
|
||||
## Table of Contents
|
||||
|
||||
|
|
@ -55,6 +57,9 @@ The integration sets different **initial defaults** because the features serve d
|
|||
|
||||
### Example Timeline
|
||||
|
||||
<details>
|
||||
<summary>Show example: Timeline</summary>
|
||||
|
||||
```
|
||||
00:00 ████████████████ Best Price Period (cheap prices)
|
||||
04:00 ░░░░░░░░░░░░░░░░ Normal
|
||||
|
|
@ -64,6 +69,8 @@ The integration sets different **initial defaults** because the features serve d
|
|||
20:00 ████████████████ Best Price Period (cheap prices)
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## How It Works
|
||||
|
|
@ -114,26 +121,39 @@ Think of it like this:
|
|||
|
||||
**Best Price:** How much MORE than the daily minimum can a price be?
|
||||
|
||||
<details>
|
||||
<summary>Show example: Search Range Flexibility</summary>
|
||||
|
||||
```
|
||||
Daily MIN: 20 ct/kWh
|
||||
Flexibility: 15% (default)
|
||||
→ Search for times ≤ 23 ct/kWh (20 + 15%)
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
**Peak Price:** How much LESS than the daily maximum can a price be?
|
||||
|
||||
<details>
|
||||
<summary>Show example: Peak Price Flexibility</summary>
|
||||
|
||||
```
|
||||
Daily MAX: 40 ct/kWh
|
||||
Flexibility: -15% (default)
|
||||
→ Search for times ≥ 34 ct/kWh (40 - 15%)
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
**Why flexibility?** Prices rarely stay at exactly MIN/MAX. Flexibility lets you capture realistic time windows.
|
||||
|
||||
#### 2. Ensure Quality (Distance from Average)
|
||||
|
||||
Periods must be meaningfully different from the daily average:
|
||||
|
||||
<details>
|
||||
<summary>Show example: Distance from Average</summary>
|
||||
|
||||
```
|
||||
Daily AVG: 30 ct/kWh
|
||||
Minimum distance: 5% (default)
|
||||
|
|
@ -142,12 +162,17 @@ Best Price: Must be ≤ 28.5 ct/kWh (30 - 5%)
|
|||
Peak Price: Must be ≥ 31.5 ct/kWh (30 + 5%)
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
**Why?** This prevents marking mediocre times as "best" just because they're slightly below average.
|
||||
|
||||
#### 3. Check Duration
|
||||
|
||||
Periods must be long enough to be practical:
|
||||
|
||||
<details>
|
||||
<summary>Show example: Minimum Period Duration</summary>
|
||||
|
||||
```
|
||||
Default: 60 minutes minimum
|
||||
|
||||
|
|
@ -155,6 +180,8 @@ Default: 60 minutes minimum
|
|||
90-minute period → Kept ✓
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
#### 4. Apply Optional Filters
|
||||
|
||||
You can optionally require:
|
||||
|
|
@ -165,6 +192,9 @@ You can optionally require:
|
|||
|
||||
Isolated price spikes are automatically detected and smoothed to prevent unnecessary period fragmentation:
|
||||
|
||||
<details>
|
||||
<summary>Show example: Automatic Price Spike Smoothing</summary>
|
||||
|
||||
```
|
||||
Original prices: 18, 19, 35, 20, 19 ct ← 35 ct is an isolated outlier
|
||||
Smoothed: 18, 19, 19, 20, 19 ct ← Spike replaced with trend prediction
|
||||
|
|
@ -172,6 +202,8 @@ Smoothed: 18, 19, 19, 20, 19 ct ← Spike replaced with trend predictio
|
|||
Result: Continuous period 00:00-01:15 instead of split periods
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
**Important:**
|
||||
- Original prices are always preserved (min/max/avg show real values)
|
||||
- Smoothing only affects which intervals are combined into periods
|
||||
|
|
@ -181,6 +213,9 @@ Result: Continuous period 00:00-01:15 instead of split periods
|
|||
|
||||
**Timeline for a typical day:**
|
||||
|
||||
<details>
|
||||
<summary>Show example: Timeline</summary>
|
||||
|
||||
```
|
||||
Hour: 00 01 02 03 04 05 06 07 08 09 10 11 12 13 14 15 16 17 18 19 20 21 22 23
|
||||
Price: 18 19 20 28 29 30 35 34 33 32 30 28 25 24 26 28 30 32 31 22 21 20 19 18
|
||||
|
|
@ -196,6 +231,8 @@ Peak Price (-15% flex = ≥29.75 ct):
|
|||
06:00-11:00 (5h)
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## Configuration Guide
|
||||
|
|
@ -208,11 +245,16 @@ Peak Price (-15% flex = ≥29.75 ct):
|
|||
**Default:** 15% (Best Price), -15% (Peak Price)
|
||||
**Range:** 0-100%
|
||||
|
||||
<details>
|
||||
<summary>Show YAML: Flexibility</summary>
|
||||
|
||||
```yaml
|
||||
best_price_flex: 15 # Can be up to 15% more expensive than daily MIN
|
||||
peak_price_flex: -15 # Can be up to 15% less expensive than daily MAX
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
**When to adjust:**
|
||||
|
||||
- **Increase (20-25%)** → Find more/longer periods
|
||||
|
|
@ -226,11 +268,16 @@ peak_price_flex: -15 # Can be up to 15% less expensive than daily MAX
|
|||
**Default:** 60 minutes (Best Price), 30 minutes (Peak Price)
|
||||
**Range:** 15-240 minutes
|
||||
|
||||
<details>
|
||||
<summary>Show YAML: Minimum Period Length</summary>
|
||||
|
||||
```yaml
|
||||
best_price_min_period_length: 60
|
||||
peak_price_min_period_length: 30
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
**When to adjust:**
|
||||
|
||||
- **Increase (90-120 min)** → Only show longer periods (e.g., for heat pump cycles)
|
||||
|
|
@ -242,11 +289,16 @@ peak_price_min_period_length: 30
|
|||
**Default:** 5%
|
||||
**Range:** 0-20%
|
||||
|
||||
<details>
|
||||
<summary>Show YAML: Distance from Average</summary>
|
||||
|
||||
```yaml
|
||||
best_price_min_distance_from_avg: 5
|
||||
peak_price_min_distance_from_avg: 5
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
**When to adjust:**
|
||||
|
||||
- **Increase (5-10%)** → Only show clearly better times
|
||||
|
|
@ -262,11 +314,16 @@ peak_price_min_distance_from_avg: 5
|
|||
**Default:** `any` (disabled)
|
||||
**Options:** `any` | `cheap` | `very_cheap` (Best Price) | `expensive` | `very_expensive` (Peak Price)
|
||||
|
||||
<details>
|
||||
<summary>Show YAML: Level Filter</summary>
|
||||
|
||||
```yaml
|
||||
best_price_max_level: any # Show any period below average
|
||||
best_price_max_level: cheap # Only show if at least one interval is CHEAP
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
**Use case:** "Only notify me when prices are objectively cheap/expensive"
|
||||
|
||||
**ℹ️ Volatility Thresholds:** The level filter also supports volatility-based levels (`volatility_low`, `volatility_medium`, `volatility_high`). These use **fixed internal thresholds** (LOW < 10%, MEDIUM < 20%, HIGH ≥ 20%) that are separate from the sensor volatility thresholds you configure in the UI. This separation ensures that changing sensor display preferences doesn't affect period calculation behavior.
|
||||
|
|
@ -277,11 +334,16 @@ best_price_max_level: cheap # Only show if at least one interval is CHEAP
|
|||
**Default:** 0 (strict)
|
||||
**Range:** 0-10
|
||||
|
||||
<details>
|
||||
<summary>Show YAML: Gap Tolerance</summary>
|
||||
|
||||
```yaml
|
||||
best_price_max_level: cheap
|
||||
best_price_max_level_gap_count: 2 # Allow up to 2 NORMAL intervals per period
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
**Use case:** "Don't split periods just because one interval isn't perfectly CHEAP"
|
||||
|
||||
### Tweaking Strategy: What to Adjust First?
|
||||
|
|
@ -292,56 +354,81 @@ When you're not happy with the default behavior, adjust settings in this order:
|
|||
|
||||
If you're not finding enough periods:
|
||||
|
||||
<details>
|
||||
<summary>Show YAML: Relaxation Defaults</summary>
|
||||
|
||||
```yaml
|
||||
enable_min_periods_best: true # Already default!
|
||||
min_periods_best: 2 # Already default!
|
||||
relaxation_attempts_best: 11 # Already default!
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
**Why start here?** Relaxation automatically finds the right balance for each day. Much easier than manual tuning.
|
||||
|
||||
#### 2. **Adjust Period Length (Simple)**
|
||||
|
||||
If periods are too short/long for your use case:
|
||||
|
||||
<details>
|
||||
<summary>Show YAML: Longer or Shorter Periods</summary>
|
||||
|
||||
```yaml
|
||||
best_price_min_period_length: 90 # Increase from 60 for longer periods
|
||||
# OR
|
||||
best_price_min_period_length: 45 # Decrease from 60 for shorter periods
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
**Safe to change:** This only affects duration, not price selection logic.
|
||||
|
||||
#### 3. **Fine-tune Flexibility (Moderate)**
|
||||
|
||||
If you consistently want more/fewer periods:
|
||||
|
||||
<details>
|
||||
<summary>Show YAML: Flexibility Tuning</summary>
|
||||
|
||||
```yaml
|
||||
best_price_flex: 20 # Increase from 15% for more periods
|
||||
# OR
|
||||
best_price_flex: 10 # Decrease from 15% for stricter selection
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
**⚠️ Watch out:** Values >25% may conflict with distance filter. Use relaxation instead.
|
||||
|
||||
#### 4. **Adjust Distance from Average (Advanced)**
|
||||
|
||||
Only if periods seem "mediocre" (not really cheap/expensive):
|
||||
|
||||
<details>
|
||||
<summary>Show YAML: Distance from Average</summary>
|
||||
|
||||
```yaml
|
||||
best_price_min_distance_from_avg: 10 # Increase from 5% for stricter quality
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
**⚠️ Careful:** High values (>10%) can make it impossible to find periods on flat price days.
|
||||
|
||||
#### 5. **Enable Level Filter (Expert)**
|
||||
|
||||
Only if you want absolute quality requirements:
|
||||
|
||||
<details>
|
||||
<summary>Show YAML: Strict Level Filter</summary>
|
||||
|
||||
```yaml
|
||||
best_price_max_level: cheap # Only show objectively CHEAP periods
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
**⚠️ Very strict:** Many days may have zero qualifying periods. **Always enable relaxation when using this!**
|
||||
|
||||
### Common Mistakes to Avoid
|
||||
|
|
@ -382,6 +469,9 @@ On days with a sharp price dip (e.g. solar midday surplus), the Best Price Perio
|
|||
|
||||
**Configuration:**
|
||||
|
||||
<details>
|
||||
<summary>Show YAML: Simple Best Price</summary>
|
||||
|
||||
```yaml
|
||||
# Use defaults - no configuration needed!
|
||||
best_price_flex: 15 # (default)
|
||||
|
|
@ -389,6 +479,8 @@ best_price_min_period_length: 60 # (default)
|
|||
best_price_min_distance_from_avg: 5 # (default)
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
**What you get:**
|
||||
|
||||
- 1-3 periods per day with prices ≤ MIN + 15%
|
||||
|
|
@ -397,6 +489,9 @@ best_price_min_distance_from_avg: 5 # (default)
|
|||
|
||||
**Automation example:**
|
||||
|
||||
<details>
|
||||
<summary>Show YAML: Dishwasher in Best Price Period</summary>
|
||||
|
||||
```yaml
|
||||
automation:
|
||||
- trigger:
|
||||
|
|
@ -409,6 +504,8 @@ automation:
|
|||
entity_id: switch.dishwasher
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
|
@ -421,12 +518,17 @@ automation:
|
|||
|
||||
This is **expected behavior** on days with very uniform electricity prices. When prices vary by less than ~10% across the day (e.g. on sunny spring days with high solar generation), there is no meaningful second "cheap window" – all hours are equally cheap. The integration automatically reduces the target to 1 period for that day.
|
||||
|
||||
<details>
|
||||
<summary>Show YAML: Flat Day Detection</summary>
|
||||
|
||||
```yaml
|
||||
min_periods_configured: 2
|
||||
periods_found_total: 1
|
||||
flat_days_detected: 1 # Uniform prices today → 1 period is the right answer
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
You don't need to change anything. This is the integration protecting you from artificial periods.
|
||||
|
||||
**If `relaxation_incomplete` is present (without `flat_days_detected`):**
|
||||
|
|
@ -434,15 +536,25 @@ You don't need to change anything. This is the integration protecting you from a
|
|||
Relaxation tried all configured attempts but couldn't reach your target. Options:
|
||||
|
||||
1. **Increase relaxation attempts** (tries more flexibility levels before giving up)
|
||||
<details>
|
||||
<summary>Show YAML example (increase relaxation attempts)</summary>
|
||||
|
||||
```yaml
|
||||
relaxation_attempts_best: 12 # Default: 11
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
2. **Reduce minimum period count**
|
||||
<details>
|
||||
<summary>Show YAML example (reduce min periods)</summary>
|
||||
|
||||
```yaml
|
||||
min_periods_best: 1 # Only require 1 period per day
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
3. **Check filter settings** – very strict `best_price_min_distance_from_avg` values block relaxation
|
||||
|
||||
---
|
||||
|
|
@ -454,25 +566,40 @@ Relaxation tried all configured attempts but couldn't reach your target. Options
|
|||
**Common Solutions:**
|
||||
|
||||
1. **Check if relaxation is enabled**
|
||||
<details>
|
||||
<summary>Show YAML example (relaxation enabled)</summary>
|
||||
|
||||
```yaml
|
||||
enable_min_periods_best: true # Should be true (default)
|
||||
min_periods_best: 2 # Try to find at least 2 periods
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
2. **If still no periods, check filters**
|
||||
- Look at sensor attributes: `relaxation_active` and `relaxation_level`
|
||||
- If relaxation exhausted all attempts: Filters too strict or flat price day
|
||||
|
||||
3. **Try increasing flexibility slightly**
|
||||
<details>
|
||||
<summary>Show YAML example (increase flexibility)</summary>
|
||||
|
||||
```yaml
|
||||
best_price_flex: 20 # Increase from default 15%
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
4. **Or reduce period length requirement**
|
||||
<details>
|
||||
<summary>Show YAML example (reduce period length)</summary>
|
||||
|
||||
```yaml
|
||||
best_price_min_period_length: 45 # Reduce from default 60 minutes
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
### Periods Split Into Small Pieces
|
||||
|
||||
**Symptom:** Many short periods instead of one long period
|
||||
|
|
@ -480,16 +607,26 @@ Relaxation tried all configured attempts but couldn't reach your target. Options
|
|||
**Common Solutions:**
|
||||
|
||||
1. **If using level filter, add gap tolerance**
|
||||
<details>
|
||||
<summary>Show YAML example (level gap tolerance)</summary>
|
||||
|
||||
```yaml
|
||||
best_price_max_level: cheap
|
||||
best_price_max_level_gap_count: 2 # Allow 2 NORMAL intervals
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
2. **Slightly increase flexibility**
|
||||
<details>
|
||||
<summary>Show YAML example (wider flexibility)</summary>
|
||||
|
||||
```yaml
|
||||
best_price_flex: 20 # From 15% → captures wider price range
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
3. **Check for price spikes**
|
||||
- Automatic smoothing should handle this
|
||||
- Check attribute: `period_interval_smoothed_count`
|
||||
|
|
@ -500,7 +637,7 @@ Relaxation tried all configured attempts but couldn't reach your target. Options
|
|||
**Key attributes to check:**
|
||||
|
||||
<details>
|
||||
<summary>Show YAML: Sensor attribute reference for best_price_period</summary>
|
||||
<summary>Show YAML: Key attributes to check</summary>
|
||||
|
||||
```yaml
|
||||
# Entity: binary_sensor.<home_name>_best_price_period
|
||||
|
|
@ -536,6 +673,9 @@ relaxation_incomplete: true # Some days couldn't reach the configured ta
|
|||
|
||||
These two values together quickly show whether the calculation achieved its goal:
|
||||
|
||||
<details>
|
||||
<summary>Show YAML: Configured Target vs Found Periods</summary>
|
||||
|
||||
```yaml
|
||||
min_periods_configured: 2 # You asked for 2 periods per day
|
||||
periods_found_total: 6 # 3 days × 2 periods = fully satisfied ✅
|
||||
|
|
@ -546,18 +686,25 @@ min_periods_configured: 2
|
|||
periods_found_total: 5 # 3 days, but one day got only 1 period
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
Note that `periods_found_total` counts **all periods across today and tomorrow** – so 4 on a two-day view means 2 per day on average.
|
||||
|
||||
**`flat_days_detected`**
|
||||
|
||||
This is the most important diagnostic for days with very uniform prices (e.g. sunny spring/summer days with high solar generation):
|
||||
|
||||
<details>
|
||||
<summary>Show YAML: Flat Days Detected Attribute</summary>
|
||||
|
||||
```yaml
|
||||
min_periods_configured: 2
|
||||
periods_found_total: 1
|
||||
flat_days_detected: 1 # ← This explains why you got 1 instead of 2
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
When prices barely change across the day – typically a variation of less than 10% – the integration automatically reduces the target from your configured value to 1. There is no meaningful second "best price window" when all prices are essentially equal.
|
||||
|
||||
**This is expected and correct behavior**, not a problem. It prevents the sensor from generating artificial periods that don't represent genuinely cheaper windows.
|
||||
|
|
@ -566,12 +713,17 @@ When prices barely change across the day – typically a variation of less than
|
|||
|
||||
This flag appears when even after all relaxation attempts, at least one day could not reach the configured minimum number of periods:
|
||||
|
||||
<details>
|
||||
<summary>Show YAML: Relaxation Incomplete Attribute</summary>
|
||||
|
||||
```yaml
|
||||
min_periods_configured: 2
|
||||
periods_found_total: 1
|
||||
relaxation_incomplete: true # ← Relaxation tried everything, still short
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
This is most common on very flat days (see above) or with very strict filter settings. If you see this repeatedly on normal days, consider:
|
||||
- Reducing `min_periods_best` to 1
|
||||
- Increasing `relaxation_attempts_best`
|
||||
|
|
@ -600,7 +752,7 @@ This is **mathematically correct behavior** caused by how electricity prices are
|
|||
**Example:**
|
||||
|
||||
<details>
|
||||
<summary>Show YAML: How identical prices can flip between best/peak across midnight</summary>
|
||||
<summary>Show YAML: Midnight Price Classification Changes</summary>
|
||||
|
||||
```yaml
|
||||
# Day 1 (low volatility, narrow range)
|
||||
|
|
@ -630,6 +782,9 @@ Daily average: 19 ct/kWh
|
|||
|
||||
Check the volatility sensors to understand if a period flip is meaningful:
|
||||
|
||||
<details>
|
||||
<summary>Show YAML: Daily Volatility Check</summary>
|
||||
|
||||
```yaml
|
||||
# Check daily volatility (available in integration)
|
||||
sensor.<home_name>_today_s_price_volatility: 8.2% # Low volatility
|
||||
|
|
@ -641,12 +796,14 @@ sensor.<home_name>_tomorrow_s_price_volatility: 7.9% # Also low
|
|||
# - Consider ignoring period classification on such days
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
**Handling in Automations:**
|
||||
|
||||
You can make your automations volatility-aware:
|
||||
|
||||
<details>
|
||||
<summary>Show YAML: 3 automation strategies for volatility-aware period control</summary>
|
||||
<summary>Show YAML: Volatility-Aware Automation</summary>
|
||||
|
||||
```yaml
|
||||
# Option 1: Only act on high-volatility days
|
||||
|
|
@ -702,6 +859,9 @@ automation:
|
|||
|
||||
Each period sensor exposes day volatility and price statistics:
|
||||
|
||||
<details>
|
||||
<summary>Show YAML: Per-Period Volatility Attributes</summary>
|
||||
|
||||
```yaml
|
||||
binary_sensor.<home_name>_best_price_period:
|
||||
day_volatility_%: 8.2 # Volatility % of the period's day
|
||||
|
|
@ -710,6 +870,8 @@ binary_sensor.<home_name>_best_price_period:
|
|||
day_price_span: 400.0 # Difference (max - min) in ct
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
These attributes allow automations to check: "Is the classification meaningful on this particular day?"
|
||||
|
||||
**Summary:**
|
||||
|
|
@ -724,7 +886,7 @@ These attributes allow automations to check: "Is the classification meaningful o
|
|||
For advanced configuration patterns and technical deep-dive, see:
|
||||
|
||||
- [Automation Examples](./automation-examples.md) - Real-world automation patterns
|
||||
- [Actions](./actions.md) - Using the `tibber_prices.get_chartdata` action for custom visualizations
|
||||
- [Chart Actions](./chart-actions.md) - Using the `tibber_prices.get_chartdata` action for custom visualizations
|
||||
|
||||
### Quick Reference
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
# Understanding Relaxation
|
||||
|
||||
> **Entity ID tip:** `<home_name>` is a placeholder for your Tibber home display name in Home Assistant. Entity IDs are derived from the displayed name (localized), so the exact slug may differ. **Can't find a sensor?** Use the **[Entity Reference (All Languages)](sensor-reference.md)** to search by name in your language.
|
||||
:::tip Entity ID tip
|
||||
`<home_name>` is a placeholder for your Tibber home display name in Home Assistant. Entity IDs are derived from the displayed name (localized), so the exact slug may differ. **Can't find a sensor?** Use the **[Entity Reference (All Languages)](sensor-reference.md)** to search by name in your language.
|
||||
:::
|
||||
|
||||
Relaxation is the automatic filter-loosening mechanism that ensures your [Best/Peak Price periods](period-calculation.md) always find results — even on days with unusual price patterns.
|
||||
|
||||
|
|
@ -12,12 +14,17 @@ Sometimes, strict filters find too few periods (or none). **Relaxation automatic
|
|||
|
||||
## How to Enable
|
||||
|
||||
<details>
|
||||
<summary>Show YAML: Enable Relaxation</summary>
|
||||
|
||||
```yaml
|
||||
enable_min_periods_best: true
|
||||
min_periods_best: 2 # Try to find at least 2 periods per day
|
||||
relaxation_attempts_best: 11 # Flex levels to test (default: 11 steps = 22 filter combinations)
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
**Good news:** Relaxation is **enabled by default** with sensible settings. Most users don't need to change anything here!
|
||||
|
||||
Set the matching `relaxation_attempts_peak` value when tuning Peak Price periods. Both sliders accept 1-12 attempts, and the default of 11 flex levels translates to 22 filter-combination tries (11 flex levels × 2 filter combos) for each of Best and Peak calculations. Lower it for quick feedback, or raise it when either sensor struggles to hit the minimum-period target on volatile days.
|
||||
|
|
@ -97,18 +104,26 @@ Each attempt adds +3% flexibility and tries two filter combinations. The system
|
|||
|
||||
**Critical:** Each day relaxes **independently**:
|
||||
|
||||
<details>
|
||||
<summary>Show formula: Each day relaxes independently</summary>
|
||||
|
||||
```
|
||||
Day 1: Finds 2 periods with flex 15% (original) → No relaxation needed
|
||||
Day 2: Needs flex 21% + level=any → Uses relaxed settings
|
||||
Day 3: Finds 2 periods with flex 15% (original) → No relaxation needed
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
**Why?** Price patterns vary daily. Some days have clear cheap/expensive windows (strict filters work), others don't (relaxation needed).
|
||||
|
||||
## Diagnosing Relaxation Behavior
|
||||
|
||||
Check the period sensor attributes to understand what happened:
|
||||
|
||||
<details>
|
||||
<summary>Show YAML: Diagnosing Relaxation Behavior</summary>
|
||||
|
||||
```yaml
|
||||
# Entity: binary_sensor.<home_name>_best_price_period
|
||||
|
||||
|
|
@ -118,6 +133,8 @@ min_periods_configured: 2 # Your target
|
|||
periods_found_total: 3 # What was actually found
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
| Attribute | Meaning |
|
||||
|-----------|---------|
|
||||
| `relaxation_active: false` | Original filters were sufficient |
|
||||
|
|
|
|||
788
docs/user/docs/scheduling-actions.md
Normal file
788
docs/user/docs/scheduling-actions.md
Normal file
|
|
@ -0,0 +1,788 @@
|
|||
# Scheduling Actions
|
||||
|
||||
Find the cheapest (or most expensive) time windows for your appliances — automatically. These actions analyze real Tibber price data and return optimal scheduling recommendations.
|
||||
|
||||
:::tip Entity ID tip
|
||||
`<home_name>` is a placeholder for your Tibber home display name in Home Assistant. Entity IDs are derived from the displayed name (localized), so the exact slug may differ. **Can't find a sensor?** Use the **[Entity Reference (All Languages)](sensor-reference.md)** to search by name in your language.
|
||||
:::
|
||||
|
||||
## Overview
|
||||
|
||||
| Action | What It Does | Best For |
|
||||
|--------|-------------|----------|
|
||||
| [`find_cheapest_block`](#find-cheapest-block) | Finds the cheapest **contiguous** time window | Dishwasher, washing machine, dryer |
|
||||
| [`find_cheapest_hours`](#find-cheapest-hours) | Finds the cheapest intervals (can be **non-contiguous**) | EV charging, battery storage, water heater |
|
||||
| [`find_cheapest_schedule`](#find-cheapest-schedule) | Schedules **multiple appliances** without overlap | Dishwasher + washing machine + dryer overnight |
|
||||
| [`find_most_expensive_block`](#find-most-expensive-block) | Finds the most expensive contiguous window | Avoid running appliances during peak prices |
|
||||
| [`find_most_expensive_hours`](#find-most-expensive-hours) | Finds the most expensive intervals | Battery discharge optimization, peak avoidance |
|
||||
|
||||
## Choosing the Right Action
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A["How many appliances?"] -->|One| B["Must it run uninterrupted?"]
|
||||
A -->|Multiple, no overlap| F["find_cheapest_schedule"]
|
||||
B -->|"Yes (dishwasher, dryer)"| C["find_cheapest_block"]
|
||||
B -->|"No (EV, battery, water heater)"| D["find_cheapest_hours"]
|
||||
C --> E["Need the opposite?<br/>→ find_most_expensive_block"]
|
||||
D --> G["Need the opposite?<br/>→ find_most_expensive_hours"]
|
||||
```
|
||||
|
||||
**Rules of thumb:**
|
||||
|
||||
- **Dishwasher, washing machine, dryer** → `find_cheapest_block` (must run X hours straight)
|
||||
- **EV charging, battery, pool pump** → `find_cheapest_hours` (total runtime matters, not continuity)
|
||||
- **Multiple appliances sharing a circuit** → `find_cheapest_schedule` (prevents overlap + manages gaps)
|
||||
- **"When should I NOT run this?"** → `find_most_expensive_block` or `find_most_expensive_hours`
|
||||
|
||||
---
|
||||
|
||||
## Search Range
|
||||
|
||||
All scheduling actions share the same flexible search range options. You can define _when_ to look for cheap prices in several ways:
|
||||
|
||||
### Quick Scopes
|
||||
|
||||
The simplest approach — use `search_scope` for common time ranges:
|
||||
|
||||
| Scope | Start | End |
|
||||
|-------|-------|-----|
|
||||
| `today` | 00:00 today | 00:00 tomorrow |
|
||||
| `tomorrow` | 00:00 tomorrow | 00:00 day after tomorrow |
|
||||
| `remaining_today` | Now | 00:00 tomorrow |
|
||||
| `next_24h` | Now | Now + 24 hours |
|
||||
| `next_48h` | Now | Now + 48 hours |
|
||||
|
||||
<details>
|
||||
<summary>Show YAML: Quick Scopes</summary>
|
||||
|
||||
```yaml
|
||||
service: tibber_prices.find_cheapest_block
|
||||
data:
|
||||
duration: "02:00:00"
|
||||
search_scope: tomorrow
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
### Explicit Start/End
|
||||
|
||||
For full control, specify exact datetime values:
|
||||
|
||||
<details>
|
||||
<summary>Show YAML: Explicit Start and End</summary>
|
||||
|
||||
```yaml
|
||||
service: tibber_prices.find_cheapest_block
|
||||
data:
|
||||
duration: "02:00:00"
|
||||
search_start: "2026-04-11T22:00:00+02:00"
|
||||
search_end: "2026-04-12T06:00:00+02:00"
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
### Time-of-Day with Day Offset
|
||||
|
||||
Schedule relative to today using time + day offset:
|
||||
|
||||
<details>
|
||||
<summary>Show YAML: Time of Day with Offset</summary>
|
||||
|
||||
```yaml
|
||||
service: tibber_prices.find_cheapest_block
|
||||
data:
|
||||
duration: "02:00:00"
|
||||
search_start_time: "22:00:00" # 22:00 today
|
||||
search_end_time: "06:00:00"
|
||||
search_end_day_offset: 1 # 06:00 tomorrow
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
### Minute Offsets from Now
|
||||
|
||||
For relative searches:
|
||||
|
||||
<details>
|
||||
<summary>Show YAML: Relative Minute Offsets</summary>
|
||||
|
||||
```yaml
|
||||
service: tibber_prices.find_cheapest_block
|
||||
data:
|
||||
duration: "01:30:00"
|
||||
search_start_offset_minutes: 0 # Starting now
|
||||
search_end_offset_minutes: 480 # Next 8 hours
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
### Default Behavior
|
||||
|
||||
If you omit all range parameters, the search covers **now until the end of tomorrow** — the maximum window with available price data.
|
||||
|
||||
:::caution Don't mix scopes with explicit ranges
|
||||
`search_scope` cannot be combined with explicit range parameters (`search_start`, `search_end`, etc.). Use one approach or the other.
|
||||
:::
|
||||
|
||||
---
|
||||
|
||||
## Common Parameters
|
||||
|
||||
These parameters are available across all scheduling actions:
|
||||
|
||||
| Parameter | Description | Default |
|
||||
|-----------|-------------|---------|
|
||||
| `entry_id` | Config entry ID. Auto-selects if you only have one home. | Auto |
|
||||
| `include_current_interval` | Include the currently running 15-minute interval in the search? | `true` |
|
||||
| `min_price_level` | Only consider intervals at or above this Tibber level | — |
|
||||
| `max_price_level` | Only consider intervals at or below this Tibber level | — |
|
||||
| `power_profile` | Watt values per 15-min interval for accurate cost estimates | — |
|
||||
| `use_base_unit` | Use base currency (EUR, NOK) instead of subunit (ct, øre) | `false` |
|
||||
|
||||
### Price Level Filtering
|
||||
|
||||
Restrict the search to specific Tibber price levels. Levels from lowest to highest: `very_cheap`, `cheap`, `normal`, `expensive`, `very_expensive`.
|
||||
|
||||
<details>
|
||||
<summary>Show YAML: Price Level Filtering</summary>
|
||||
|
||||
```yaml
|
||||
# Only search within cheap or very cheap intervals
|
||||
service: tibber_prices.find_cheapest_block
|
||||
data:
|
||||
duration: "02:00:00"
|
||||
search_scope: next_24h
|
||||
max_price_level: cheap # Exclude normal, expensive, very_expensive
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
### Power Profile
|
||||
|
||||
By default, cost estimates assume a constant 1 kW load. If your appliance has variable power draw, provide a power profile — **one watt value per 15-minute interval**:
|
||||
|
||||
<details>
|
||||
<summary>Show YAML: Power Profile</summary>
|
||||
|
||||
```yaml
|
||||
# Washing machine: high power for heating, then less
|
||||
service: tibber_prices.find_cheapest_block
|
||||
data:
|
||||
duration: "01:30:00" # 6 intervals × 15 min
|
||||
power_profile:
|
||||
- 2200 # Interval 1: Heating water (2.2 kW)
|
||||
- 2200 # Interval 2: Heating continues
|
||||
- 800 # Interval 3: Washing cycle
|
||||
- 800 # Interval 4: Washing cycle
|
||||
- 1500 # Interval 5: Spin cycle
|
||||
- 500 # Interval 6: Final rinse
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
The service then calculates `estimated_total_cost` using the actual power draw per interval instead of flat 1 kW, and adds `estimated_load_kwh` (total energy consumed) to the response.
|
||||
|
||||
:::info Duration and profile must match
|
||||
The number of entries in `power_profile` must exactly match the number of 15-minute intervals in `duration`. A 2-hour duration needs 8 entries.
|
||||
:::
|
||||
|
||||
---
|
||||
|
||||
## Find Cheapest Block
|
||||
|
||||
Finds the single cheapest **contiguous** time window of a given duration.
|
||||
|
||||
**Use when:** Your appliance must run uninterrupted for a fixed time.
|
||||
|
||||
### Basic Example
|
||||
|
||||
<details>
|
||||
<summary>Show YAML: Find Cheapest Block</summary>
|
||||
|
||||
```yaml
|
||||
service: tibber_prices.find_cheapest_block
|
||||
data:
|
||||
duration: "02:00:00"
|
||||
search_scope: next_24h
|
||||
response_variable: result
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
### Example with All Options
|
||||
|
||||
<details>
|
||||
<summary>Show YAML: Cheapest Block with All Options</summary>
|
||||
|
||||
```yaml
|
||||
service: tibber_prices.find_cheapest_block
|
||||
data:
|
||||
duration: "02:00:00"
|
||||
search_scope: next_24h
|
||||
max_price_level: normal
|
||||
power_profile: [2200, 2200, 800, 800, 1500, 500, 400, 200]
|
||||
include_comparison_details: true
|
||||
include_current_interval: false
|
||||
response_variable: result
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
### Response
|
||||
|
||||
<details>
|
||||
<summary>Show JSON: Cheapest Block Example Response</summary>
|
||||
|
||||
```json
|
||||
{
|
||||
"home_id": "abc-123",
|
||||
"search_start": "2026-04-11T14:00:00+02:00",
|
||||
"search_end": "2026-04-12T14:00:00+02:00",
|
||||
"duration_minutes_requested": 120,
|
||||
"duration_minutes": 120,
|
||||
"currency": "EUR",
|
||||
"price_unit": "ct/kWh",
|
||||
"window_found": true,
|
||||
"window": {
|
||||
"start": "2026-04-12T02:00:00+02:00",
|
||||
"end": "2026-04-12T04:00:00+02:00",
|
||||
"duration_minutes": 120,
|
||||
"interval_count": 8,
|
||||
"price_mean": 14.25,
|
||||
"price_median": 13.90,
|
||||
"price_min": 12.00,
|
||||
"price_max": 16.80,
|
||||
"price_spread": 4.80,
|
||||
"estimated_total_cost": 28.50,
|
||||
"intervals": [
|
||||
{
|
||||
"starts_at": "2026-04-12T02:00:00+02:00",
|
||||
"ends_at": "2026-04-12T02:15:00+02:00",
|
||||
"price": 12.00,
|
||||
"level": "very_cheap",
|
||||
"rating_level": "low"
|
||||
}
|
||||
]
|
||||
},
|
||||
"price_comparison": {
|
||||
"comparison_price_mean": 32.10,
|
||||
"price_difference": 17.85,
|
||||
"comparison_window_start": "2026-04-11T18:00:00+02:00"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
**Key response fields:**
|
||||
|
||||
| Field | Description |
|
||||
|-------|-------------|
|
||||
| `window_found` | `true` if a window was found, `false` if no intervals match the criteria |
|
||||
| `window.start` / `window.end` | When to start and stop the appliance |
|
||||
| `window.price_mean` | Average price during the window |
|
||||
| `window.estimated_total_cost` | Estimated cost (assumes 1 kW unless `power_profile` provided) |
|
||||
| `price_comparison` | How this window compares to the most expensive alternative |
|
||||
| `price_comparison.price_difference` | How much cheaper this window is vs. the most expensive option |
|
||||
|
||||
### Use in Automations
|
||||
|
||||
<details>
|
||||
<summary>Show YAML: Dishwasher Automation</summary>
|
||||
|
||||
```yaml
|
||||
automation:
|
||||
- alias: "Start dishwasher at cheapest time"
|
||||
trigger:
|
||||
- platform: time
|
||||
at: "20:00:00"
|
||||
action:
|
||||
- service: tibber_prices.find_cheapest_block
|
||||
data:
|
||||
duration: "02:00:00"
|
||||
search_start_time: "20:00:00"
|
||||
search_end_time: "06:00:00"
|
||||
search_end_day_offset: 1
|
||||
response_variable: result
|
||||
- if: "{{ result.window_found }}"
|
||||
then:
|
||||
- service: automation.turn_on
|
||||
target:
|
||||
entity_id: automation.run_dishwasher
|
||||
- delay:
|
||||
hours: "{{ ((result.window.start | as_datetime - now()) .total_seconds() / 3600) | int }}"
|
||||
minutes: "{{ (((result.window.start | as_datetime - now()).total_seconds() % 3600) / 60) | int }}"
|
||||
- service: switch.turn_on
|
||||
target:
|
||||
entity_id: switch.dishwasher_smart_plug
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## Find Cheapest Hours
|
||||
|
||||
Finds the cheapest N minutes of intervals within a search range. Intervals **do not need to be contiguous** — the service picks the cheapest individual 15-minute slots and groups them into segments.
|
||||
|
||||
**Use when:** Your device can pause and resume freely (EV charger, battery storage, pool pump).
|
||||
|
||||
### Basic Example
|
||||
|
||||
<details>
|
||||
<summary>Show YAML: Find Cheapest Hours</summary>
|
||||
|
||||
```yaml
|
||||
service: tibber_prices.find_cheapest_hours
|
||||
data:
|
||||
duration: "04:00:00"
|
||||
search_scope: next_24h
|
||||
response_variable: result
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
### With Minimum Segment Duration
|
||||
|
||||
Some devices shouldn't cycle on/off too rapidly. Use `min_segment_duration` to ensure each contiguous run is at least a minimum length:
|
||||
|
||||
<details>
|
||||
<summary>Show YAML: With Minimum Segment Duration</summary>
|
||||
|
||||
```yaml
|
||||
# EV charger: 3 hours total, but each charging session at least 30 min
|
||||
service: tibber_prices.find_cheapest_hours
|
||||
data:
|
||||
duration: "03:00:00"
|
||||
min_segment_duration: "00:30:00"
|
||||
search_scope: next_24h
|
||||
response_variable: result
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
### Response
|
||||
|
||||
<details>
|
||||
<summary>Show JSON: Cheapest Hours Example Response</summary>
|
||||
|
||||
```json
|
||||
{
|
||||
"home_id": "abc-123",
|
||||
"search_start": "2026-04-11T14:00:00+02:00",
|
||||
"search_end": "2026-04-12T14:00:00+02:00",
|
||||
"total_minutes_requested": 240,
|
||||
"total_minutes": 240,
|
||||
"currency": "EUR",
|
||||
"price_unit": "ct/kWh",
|
||||
"intervals_found": true,
|
||||
"schedule": {
|
||||
"total_minutes": 240,
|
||||
"interval_count": 16,
|
||||
"price_mean": 13.50,
|
||||
"price_median": 13.20,
|
||||
"price_min": 10.80,
|
||||
"price_max": 16.30,
|
||||
"price_spread": 5.50,
|
||||
"estimated_total_cost": 54.00,
|
||||
"segment_count": 3,
|
||||
"segments": [
|
||||
{
|
||||
"start": "2026-04-11T23:00:00+02:00",
|
||||
"end": "2026-04-12T00:30:00+02:00",
|
||||
"duration_minutes": 90,
|
||||
"interval_count": 6,
|
||||
"price_mean": 11.20,
|
||||
"intervals": []
|
||||
},
|
||||
{
|
||||
"start": "2026-04-12T02:00:00+02:00",
|
||||
"end": "2026-04-12T03:15:00+02:00",
|
||||
"duration_minutes": 75,
|
||||
"interval_count": 5,
|
||||
"price_mean": 12.80,
|
||||
"intervals": []
|
||||
},
|
||||
{
|
||||
"start": "2026-04-12T05:00:00+02:00",
|
||||
"end": "2026-04-12T06:15:00+02:00",
|
||||
"duration_minutes": 75,
|
||||
"interval_count": 5,
|
||||
"price_mean": 16.00,
|
||||
"intervals": []
|
||||
}
|
||||
],
|
||||
"intervals": []
|
||||
},
|
||||
"price_comparison": {
|
||||
"comparison_price_mean": 28.50,
|
||||
"price_difference": 15.00
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
**Key response fields:**
|
||||
|
||||
| Field | Description |
|
||||
|-------|-------------|
|
||||
| `intervals_found` | `true` if enough cheap intervals were found |
|
||||
| `schedule.segment_count` | How many separate contiguous runs the schedule has |
|
||||
| `schedule.segments[]` | Each continuous "on" period with its own start/end and price stats |
|
||||
| `schedule.intervals[]` | All selected intervals in chronological order |
|
||||
|
||||
---
|
||||
|
||||
## Find Cheapest Schedule
|
||||
|
||||
Schedules **multiple appliances** within the same search range, ensuring they don't overlap. Each appliance gets its own cheapest contiguous time window.
|
||||
|
||||
**Use when:** You have multiple appliances sharing a circuit or you want to avoid running them at the same time (e.g., limited main fuse capacity).
|
||||
|
||||
### How It Works
|
||||
|
||||
1. Tasks are sorted by duration (longest first — harder to place)
|
||||
2. The longest task claims the cheapest contiguous block
|
||||
3. Those intervals are marked as **unavailable**
|
||||
4. The next task finds the cheapest block in the **remaining** intervals
|
||||
5. Optional gap between tasks ensures a pause (e.g., for shared plumbing or circuit recovery)
|
||||
|
||||
### Basic Example
|
||||
|
||||
<details>
|
||||
<summary>Show YAML: Find Cheapest Schedule</summary>
|
||||
|
||||
```yaml
|
||||
service: tibber_prices.find_cheapest_schedule
|
||||
data:
|
||||
tasks:
|
||||
- name: dishwasher
|
||||
duration: "02:00:00"
|
||||
- name: washing_machine
|
||||
duration: "01:30:00"
|
||||
search_scope: next_24h
|
||||
response_variable: result
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
### With Gap and Power Profiles
|
||||
|
||||
<details>
|
||||
<summary>Show YAML: With Gap and Power Profiles</summary>
|
||||
|
||||
```yaml
|
||||
service: tibber_prices.find_cheapest_schedule
|
||||
data:
|
||||
tasks:
|
||||
- name: dishwasher
|
||||
duration: "02:00:00"
|
||||
power_profile: [2200, 2200, 800, 800, 1500, 500, 400, 200]
|
||||
- name: washing_machine
|
||||
duration: "01:30:00"
|
||||
power_profile: [2000, 2000, 800, 800, 1200, 500]
|
||||
- name: dryer
|
||||
duration: "01:00:00"
|
||||
power_profile: [2500, 2500, 2000, 1500]
|
||||
gap_minutes: 15
|
||||
search_start_time: "22:00:00"
|
||||
search_end_time: "07:00:00"
|
||||
search_end_day_offset: 1
|
||||
response_variable: result
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
### Response
|
||||
|
||||
<details>
|
||||
<summary>Show JSON: Cheapest Schedule Example Response</summary>
|
||||
|
||||
```json
|
||||
{
|
||||
"home_id": "abc-123",
|
||||
"search_start": "2026-04-11T22:00:00+02:00",
|
||||
"search_end": "2026-04-12T07:00:00+02:00",
|
||||
"currency": "EUR",
|
||||
"price_unit": "ct/kWh",
|
||||
"all_tasks_scheduled": true,
|
||||
"unscheduled_tasks": null,
|
||||
"tasks": [
|
||||
{
|
||||
"name": "dishwasher",
|
||||
"start": "2026-04-12T00:00:00+02:00",
|
||||
"end": "2026-04-12T02:00:00+02:00",
|
||||
"duration_minutes_requested": 120,
|
||||
"duration_minutes": 120,
|
||||
"price_mean": 12.30,
|
||||
"price_median": 12.10,
|
||||
"price_min": 10.50,
|
||||
"price_max": 14.20,
|
||||
"price_spread": 3.70,
|
||||
"estimated_total_cost": 24.60,
|
||||
"intervals": []
|
||||
},
|
||||
{
|
||||
"name": "washing_machine",
|
||||
"start": "2026-04-12T02:15:00+02:00",
|
||||
"end": "2026-04-12T03:45:00+02:00",
|
||||
"duration_minutes_requested": 90,
|
||||
"duration_minutes": 90,
|
||||
"price_mean": 13.80,
|
||||
"price_median": 13.50,
|
||||
"price_min": 12.00,
|
||||
"price_max": 16.10,
|
||||
"price_spread": 4.10,
|
||||
"estimated_total_cost": 20.70,
|
||||
"intervals": []
|
||||
},
|
||||
{
|
||||
"name": "dryer",
|
||||
"start": "2026-04-12T04:00:00+02:00",
|
||||
"end": "2026-04-12T05:00:00+02:00",
|
||||
"duration_minutes_requested": 60,
|
||||
"duration_minutes": 60,
|
||||
"price_mean": 14.50,
|
||||
"price_median": 14.30,
|
||||
"price_min": 13.80,
|
||||
"price_max": 15.40,
|
||||
"price_spread": 1.60,
|
||||
"estimated_total_cost": 14.50,
|
||||
"intervals": []
|
||||
}
|
||||
],
|
||||
"total_estimated_cost": 59.80
|
||||
}
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
**Key response fields:**
|
||||
|
||||
| Field | Description |
|
||||
|-------|-------------|
|
||||
| `all_tasks_scheduled` | `true` if every task found a slot, `false` if some couldn't fit |
|
||||
| `unscheduled_tasks` | List of task names that couldn't be placed (or `null` if all succeeded) |
|
||||
| `tasks[]` | Each task with its assigned time window and price statistics |
|
||||
| `tasks[].start` / `tasks[].end` | When to start and stop each appliance |
|
||||
| `total_estimated_cost` | Combined cost across all tasks |
|
||||
|
||||
### Why Not Just Call find_cheapest_block Multiple Times?
|
||||
|
||||
If you call `find_cheapest_block` separately for each appliance, they might all find the **same** cheap time window. `find_cheapest_schedule` solves this by tracking which intervals are already claimed — each appliance gets its own non-overlapping slot.
|
||||
|
||||
### Gap Minutes
|
||||
|
||||
Use `gap_minutes` to add a mandatory pause between appliances:
|
||||
|
||||
- **Shared plumbing**: 15 min gap between dishwasher and washing machine
|
||||
- **Circuit protection**: 30 min gap to let cables cool down
|
||||
- **Heat pump compressor**: 15–30 min cool-down between cycles
|
||||
|
||||
The gap is rounded up to the nearest 15 minutes (quarter-hour granularity).
|
||||
|
||||
---
|
||||
|
||||
## Find Most Expensive Block
|
||||
|
||||
The opposite of `find_cheapest_block` — finds the most expensive contiguous window.
|
||||
|
||||
**Parameters:** Identical to `find_cheapest_block`.
|
||||
|
||||
**Response:** Same structure. The `price_comparison` compares against the cheapest block.
|
||||
|
||||
<details>
|
||||
<summary>Show YAML: Find Most Expensive Block</summary>
|
||||
|
||||
```yaml
|
||||
service: tibber_prices.find_most_expensive_block
|
||||
data:
|
||||
duration: "02:00:00"
|
||||
search_scope: tomorrow
|
||||
response_variable: peak
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
**Use cases:**
|
||||
- "When should I definitely NOT run my washing machine?"
|
||||
- Schedule battery discharge during peak prices
|
||||
- Send notifications before expensive periods start
|
||||
|
||||
---
|
||||
|
||||
## Find Most Expensive Hours
|
||||
|
||||
The opposite of `find_cheapest_hours` — finds the most expensive intervals (non-contiguous).
|
||||
|
||||
**Parameters:** Identical to `find_cheapest_hours` (including `min_segment_duration`).
|
||||
|
||||
**Response:** Same structure. The `price_comparison` compares against the cheapest hours.
|
||||
|
||||
<details>
|
||||
<summary>Show YAML: Find Most Expensive Hours</summary>
|
||||
|
||||
```yaml
|
||||
service: tibber_prices.find_most_expensive_hours
|
||||
data:
|
||||
duration: "04:00:00"
|
||||
search_scope: tomorrow
|
||||
response_variable: peak
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
**Use cases:**
|
||||
- Battery discharge optimization: sell stored energy during the most expensive 4 hours
|
||||
- Demand response: reduce consumption during the most expensive periods
|
||||
- Peak avoidance alerts: notify before expensive intervals start
|
||||
|
||||
---
|
||||
|
||||
## Practical Examples
|
||||
|
||||
### Overnight Appliance Scheduling
|
||||
|
||||
Schedule dishwasher + washing machine to run overnight at cheapest prices, with a 15-minute gap between them:
|
||||
|
||||
<details>
|
||||
<summary>Show YAML: Overnight Appliance Scheduling</summary>
|
||||
|
||||
```yaml
|
||||
automation:
|
||||
- alias: "Schedule overnight appliances"
|
||||
trigger:
|
||||
- platform: time
|
||||
at: "21:00:00"
|
||||
action:
|
||||
- service: tibber_prices.find_cheapest_schedule
|
||||
data:
|
||||
tasks:
|
||||
- name: dishwasher
|
||||
duration: "02:00:00"
|
||||
- name: washing_machine
|
||||
duration: "01:30:00"
|
||||
gap_minutes: 15
|
||||
search_start_time: "22:00:00"
|
||||
search_end_time: "06:00:00"
|
||||
search_end_day_offset: 1
|
||||
response_variable: schedule
|
||||
- if: "{{ schedule.all_tasks_scheduled }}"
|
||||
then:
|
||||
- service: notify.mobile_app
|
||||
data:
|
||||
title: "Appliance Schedule Ready"
|
||||
message: >
|
||||
Dishwasher: {{ schedule.tasks[0].start | as_datetime | as_local }}
|
||||
Washing machine: {{ schedule.tasks[1].start | as_datetime | as_local }}
|
||||
Total cost: {{ schedule.total_estimated_cost | round(2) }} ct
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
### EV Charging During Cheapest 4 Hours
|
||||
|
||||
<details>
|
||||
<summary>Show YAML: EV Charging in Cheapest 4 Hours</summary>
|
||||
|
||||
```yaml
|
||||
automation:
|
||||
- alias: "Smart EV charging"
|
||||
trigger:
|
||||
- platform: time
|
||||
at: "18:00:00"
|
||||
condition:
|
||||
- condition: numeric_state
|
||||
entity_id: sensor.ev_battery_level
|
||||
below: 80
|
||||
action:
|
||||
- service: tibber_prices.find_cheapest_hours
|
||||
data:
|
||||
duration: "04:00:00"
|
||||
min_segment_duration: "00:30:00"
|
||||
search_start_time: "18:00:00"
|
||||
search_end_time: "07:00:00"
|
||||
search_end_day_offset: 1
|
||||
response_variable: charging
|
||||
- if: "{{ charging.intervals_found }}"
|
||||
then:
|
||||
- service: notify.mobile_app
|
||||
data:
|
||||
title: "EV Charging Plan"
|
||||
message: >
|
||||
{{ charging.schedule.segment_count }} charging sessions planned.
|
||||
Average price: {{ charging.schedule.price_mean | round(1) }} ct/kWh
|
||||
Savings vs. peak: {{ charging.price_comparison.price_difference | round(1) }} ct/kWh
|
||||
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
### Peak Price Warning
|
||||
|
||||
<details>
|
||||
<summary>Show YAML: Peak Price Warning</summary>
|
||||
|
||||
```yaml
|
||||
automation:
|
||||
- alias: "Peak price warning"
|
||||
trigger:
|
||||
- platform: time
|
||||
at: "08:00:00"
|
||||
action:
|
||||
- service: tibber_prices.find_most_expensive_block
|
||||
data:
|
||||
duration: "02:00:00"
|
||||
search_scope: today
|
||||
response_variable: peak
|
||||
- if: "{{ peak.window_found }}"
|
||||
then:
|
||||
- service: notify.mobile_app
|
||||
data:
|
||||
title: "⚡ Expensive Period Today"
|
||||
message: >
|
||||
Avoid high-power appliances between
|
||||
{{ peak.window.start | as_datetime | as_local }}
|
||||
and {{ peak.window.end | as_datetime | as_local }}.
|
||||
Average price: {{ peak.window.price_mean | round(1) }} ct/kWh
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## Technical Notes
|
||||
|
||||
### Duration Rounding
|
||||
|
||||
All durations are rounded **up** to the nearest 15 minutes because Tibber price data has quarter-hourly resolution. A 20-minute duration becomes 30 minutes (2 intervals). A 2-hour duration stays at 120 minutes (8 intervals).
|
||||
|
||||
### Comparison Details
|
||||
|
||||
Add `include_comparison_details: true` to `find_cheapest_block` or `find_cheapest_hours` to get extra fields in the comparison:
|
||||
|
||||
<details>
|
||||
<summary>Show YAML: Comparison Details</summary>
|
||||
|
||||
```yaml
|
||||
service: tibber_prices.find_cheapest_block
|
||||
data:
|
||||
duration: "02:00:00"
|
||||
include_comparison_details: true
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
This adds `comparison_price_min`, `comparison_price_max`, and `comparison_window_end` to the `price_comparison` object.
|
||||
|
||||
### Response When No Window Found
|
||||
|
||||
If no intervals match your criteria (e.g., the search range is too short, all intervals are filtered out by price level), the response indicates failure:
|
||||
|
||||
- `find_cheapest_block`: `"window_found": false, "window": null`
|
||||
- `find_cheapest_hours`: `"intervals_found": false, "schedule": null`
|
||||
- `find_cheapest_schedule`: `"all_tasks_scheduled": false, "unscheduled_tasks": ["task_name"]`
|
||||
|
||||
Always check these fields in your automations before using the results.
|
||||
|
|
@ -208,6 +208,15 @@ explanations of each sensor's purpose, attributes, and automation examples.
|
|||
| <span id="ref-data_lifecycle_status" class="entity-anchor"></span>`data_lifecycle_status` | Data Lifecycle Status | Datenlebenszyklus-Status | Datalivssyklus-status | Data Levenscyclus Status | Datalivscykelstatus | ✅ |
|
||||
| <span id="ref-chart_data_export" class="entity-anchor"></span>`chart_data_export` | Chart Data Export | Diagramm-Datenexport | Diagramdataeksport | Grafiekdata Export | Diagramdataexport | ❌ |
|
||||
| <span id="ref-chart_metadata" class="entity-anchor"></span>`chart_metadata` | Chart Metadata | Diagramm-Metadaten | Diagrammetadata | Grafiek Metadata | Diagrammetadata | ✅ |
|
||||
|
||||
### Other
|
||||
|
||||
|
||||
| Entity ID suffix | 🇬🇧 English | 🇩🇪 Deutsch | 🇳🇴 Norsk | 🇳🇱 Nederlands | 🇸🇪 Svenska | Default |
|
||||
|---|---|---|---|---|---|---|
|
||||
| <span id="ref-day_pattern_today" class="entity-anchor"></span>`day_pattern_today` | Today's Price Pattern | Preismuster Heute | Prismønster i dag | Prijspatroon Vandaag | Prismönster Idag | ✅ |
|
||||
| <span id="ref-day_pattern_tomorrow" class="entity-anchor"></span>`day_pattern_tomorrow` | Tomorrow's Price Pattern | Preismuster Morgen | Prismønster i morgen | Prijspatroon Morgen | Prismönster Imorgon | ❌ |
|
||||
| <span id="ref-day_pattern_yesterday" class="entity-anchor"></span>`day_pattern_yesterday` | Yesterday's Price Pattern | Preismuster Gestern | Prismønster i går | Prijspatroon Gisteren | Prismönster Igår | ❌ |
|
||||
## Binary Sensors
|
||||
|
||||
### Binary Sensors
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
# Average & Statistics Sensors
|
||||
|
||||
> **Entity ID tip:** `<home_name>` is a placeholder for your Tibber home display name in Home Assistant. Entity IDs are derived from the displayed name (localized), so the exact slug may differ. **Can't find a sensor?** Use the **[Entity Reference (All Languages)](sensor-reference.md)** to search by name in your language.
|
||||
:::tip Entity ID tip
|
||||
`<home_name>` is a placeholder for your Tibber home display name in Home Assistant. Entity IDs are derived from the displayed name (localized), so the exact slug may differ. **Can't find a sensor?** Use the **[Entity Reference (All Languages)](sensor-reference.md)** to search by name in your language.
|
||||
:::
|
||||
|
||||
The integration provides several sensors that calculate average electricity prices over different time windows. These sensors show a **typical** price value that represents the overall price level, helping you make informed decisions about when to use electricity.
|
||||
|
||||
|
|
@ -25,6 +27,9 @@ All average sensors support **two different calculation methods** for the state
|
|||
|
||||
**Why two values matter:**
|
||||
|
||||
<details>
|
||||
<summary>Show YAML: Why two values matter</summary>
|
||||
|
||||
```yaml
|
||||
# Example price data for one day:
|
||||
# Prices: 10, 12, 13, 15, 80 ct/kWh (one extreme spike)
|
||||
|
|
@ -33,6 +38,8 @@ All average sensors support **two different calculation methods** for the state
|
|||
# Mean = 26 ct/kWh ← Mathematical average (affected by spike)
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
The median shows you what price level was **typical** during that period, while the mean shows the actual **average cost** if you consumed evenly throughout the period.
|
||||
|
||||
## Configuring the Display
|
||||
|
|
@ -52,6 +59,9 @@ You can choose which value is displayed in the sensor state:
|
|||
|
||||
Both `price_mean` and `price_median` are always available as attributes:
|
||||
|
||||
<details>
|
||||
<summary>Show YAML: Mean and Median in Automations</summary>
|
||||
|
||||
```yaml
|
||||
# Example: Get both values regardless of display setting
|
||||
sensor:
|
||||
|
|
@ -73,6 +83,8 @@ sensor:
|
|||
{% endif %}
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
## Practical Examples
|
||||
|
||||
**Example 1: Smart dishwasher control**
|
||||
|
|
@ -80,7 +92,7 @@ sensor:
|
|||
Run dishwasher only when price is significantly below the daily typical level:
|
||||
|
||||
<details>
|
||||
<summary>Show YAML: Automation — start dishwasher when cheap</summary>
|
||||
<summary>Show YAML: Practical Examples</summary>
|
||||
|
||||
```yaml
|
||||
automation:
|
||||
|
|
@ -108,7 +120,7 @@ automation:
|
|||
Use mean for actual cost calculations:
|
||||
|
||||
<details>
|
||||
<summary>Show YAML: Automation — cost-aware heating control</summary>
|
||||
<summary>Show YAML: Mean for Cost Calculations</summary>
|
||||
|
||||
```yaml
|
||||
automation:
|
||||
|
|
@ -135,7 +147,7 @@ automation:
|
|||
Use trailing average to understand recent price trends:
|
||||
|
||||
<details>
|
||||
<summary>Show YAML: Automation — EV charging based on rolling average</summary>
|
||||
<summary>Show YAML: Practical Examples</summary>
|
||||
|
||||
```yaml
|
||||
automation:
|
||||
|
|
|
|||
|
|
@ -1,13 +1,20 @@
|
|||
# Energy & Tax Attributes
|
||||
|
||||
> **Entity ID tip:** `<home_name>` is a placeholder for your Tibber home display name in Home Assistant. Entity IDs are derived from the displayed name (localized), so the exact slug may differ. **Can't find a sensor?** Use the **[Entity Reference (All Languages)](sensor-reference.md)** to search by name in your language.
|
||||
:::tip Entity ID tip
|
||||
`<home_name>` is a placeholder for your Tibber home display name in Home Assistant. Entity IDs are derived from the displayed name (localized), so the exact slug may differ. **Can't find a sensor?** Use the **[Entity Reference (All Languages)](sensor-reference.md)** to search by name in your language.
|
||||
:::
|
||||
|
||||
Most price sensors include **energy price** and **tax** attributes that break down the total price into its components:
|
||||
|
||||
<details>
|
||||
<summary>Show formula</summary>
|
||||
|
||||
```
|
||||
total = energy_price + tax
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
These attributes appear on **all price sensors that display a raw price** (not on percentage, level, or trend sensors). The `energy_price` is the raw spot/market price, while `tax` includes all fees, surcharges, and taxes added by your electricity provider.
|
||||
|
||||
:::note Transition After Update
|
||||
|
|
@ -33,7 +40,7 @@ After updating the integration, the `energy_price` and `tax` attributes will app
|
|||
In countries like the Netherlands, solar feed-in compensation is based on the **raw energy/spot price**, not the total consumer price. The `energy_price` attribute gives you exactly this value — no more reverse-engineering from the total price with fragile template calculations.
|
||||
|
||||
<details>
|
||||
<summary>Show YAML: Automation — solar export or consume decision</summary>
|
||||
<summary>Show YAML: Solar Feed-In and Net Metering</summary>
|
||||
|
||||
```yaml
|
||||
# Example: Decide whether to export solar power or consume it
|
||||
|
|
@ -63,7 +70,7 @@ automation:
|
|||
Understand how your electricity price is structured — useful for comparing across days or spotting trends in market prices vs. fees:
|
||||
|
||||
<details>
|
||||
<summary>Show YAML: Template sensor — electricity tax share percentage</summary>
|
||||
<summary>Show YAML: Price Composition Analysis</summary>
|
||||
|
||||
```yaml
|
||||
# Template sensor showing tax share
|
||||
|
|
@ -87,6 +94,9 @@ template:
|
|||
|
||||
Show users how today's average price splits into energy vs. tax:
|
||||
|
||||
<details>
|
||||
<summary>Show YAML: Daily Cost Breakdown</summary>
|
||||
|
||||
```yaml
|
||||
# Mushroom chips card showing the split
|
||||
type: custom:mushroom-chips-card
|
||||
|
|
@ -101,10 +111,12 @@ chips:
|
|||
🏛️ {{ state_attr('sensor.<home_name>_price_today', 'tax_mean') | round(1) }} ct
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
## Country-Specific Calculations
|
||||
|
||||
The composition of the `tax` field varies by country (Norway, Sweden, Germany, Netherlands each have different fee structures). For detailed examples of how to build country-specific calculations using `input_number` helpers and template sensors — including **Dutch solar feed-in compensation (saldering)** — see the **[Community Examples](community-examples.md#country-specific-price-calculations)** page.
|
||||
|
||||
## In Chart Data Actions
|
||||
|
||||
The `energy_price` and `tax` fields are also available in the `get_chartdata` action. See [Actions — Energy & Tax Fields](./actions.md#energy--tax-fields-in-get_chartdata) for details.
|
||||
The `energy_price` and `tax` fields are also available in the `get_chartdata` action. See [Chart Actions — Energy & Tax Fields](./chart-actions.md#energy--tax-fields) for details.
|
||||
|
|
|
|||
|
|
@ -2,7 +2,9 @@
|
|||
|
||||
> **Tip:** Many sensors have dynamic icons and colors! See the **[Dynamic Icons Guide](dynamic-icons.md)** and **[Dynamic Icon Colors Guide](icon-colors.md)** to enhance your dashboards.
|
||||
|
||||
> **Entity ID tip:** `<home_name>` is a placeholder for your Tibber home display name in Home Assistant. Entity IDs are derived from the displayed name (localized), so the exact slug may differ. **Can't find a sensor?** Use the **[Entity Reference (All Languages)](sensor-reference.md)** to search by name in your language.
|
||||
:::tip Entity ID tip
|
||||
`<home_name>` is a placeholder for your Tibber home display name in Home Assistant. Entity IDs are derived from the displayed name (localized), so the exact slug may differ. **Can't find a sensor?** Use the **[Entity Reference (All Languages)](sensor-reference.md)** to search by name in your language.
|
||||
:::
|
||||
|
||||
The integration provides **100+ sensors** organized by purpose. This page gives a quick overview and links to detailed guides for each sensor family.
|
||||
|
||||
|
|
@ -154,6 +156,9 @@ See the `tibber_prices.get_chartdata` action documentation for a complete list o
|
|||
|
||||
**Example Usage:**
|
||||
|
||||
<details>
|
||||
<summary>Show YAML: Example Usage</summary>
|
||||
|
||||
```yaml
|
||||
# ApexCharts card consuming the sensor
|
||||
type: custom:apexcharts-card
|
||||
|
|
@ -163,10 +168,15 @@ series:
|
|||
return entity.attributes.data;
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
**Migration Path:**
|
||||
|
||||
If you're currently using this sensor, consider migrating to the action:
|
||||
|
||||
<details>
|
||||
<summary>Show YAML: Chart Data Export</summary>
|
||||
|
||||
```yaml
|
||||
# Old approach (sensor)
|
||||
- service: apexcharts_card.update
|
||||
|
|
@ -181,3 +191,5 @@ If you're currently using this sensor, consider migrating to the action:
|
|||
output_format: array_of_objects
|
||||
response_variable: chart_data
|
||||
```
|
||||
|
||||
</details>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
# Ratings & Levels
|
||||
|
||||
> **Entity ID tip:** `<home_name>` is a placeholder for your Tibber home display name in Home Assistant. Entity IDs are derived from the displayed name (localized), so the exact slug may differ. **Can't find a sensor?** Use the **[Entity Reference (All Languages)](sensor-reference.md)** to search by name in your language.
|
||||
:::tip Entity ID tip
|
||||
`<home_name>` is a placeholder for your Tibber home display name in Home Assistant. Entity IDs are derived from the displayed name (localized), so the exact slug may differ. **Can't find a sensor?** Use the **[Entity Reference (All Languages)](sensor-reference.md)** to search by name in your language.
|
||||
:::
|
||||
|
||||
The integration provides **two** classification systems for electricity prices. Both are useful, but serve different purposes.
|
||||
|
||||
|
|
@ -31,10 +33,15 @@ Rating sensors classify prices relative to the **trailing 24-hour average**, ans
|
|||
|
||||
The integration calculates a **percentage difference** between the current price and the trailing 24-hour average:
|
||||
|
||||
<details>
|
||||
<summary>Show formula: How Ratings Work</summary>
|
||||
|
||||
```
|
||||
difference = ((current_price - trailing_avg) / abs(trailing_avg)) × 100%
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
This percentage is then classified:
|
||||
|
||||
| Rating | Condition (default) | Meaning |
|
||||
|
|
@ -86,6 +93,9 @@ stateDiagram-v2
|
|||
|
||||
**Best Practice:** Always use the `rating_level` attribute (lowercase English) instead of the sensor state (which is translated to your HA language):
|
||||
|
||||
<details>
|
||||
<summary>Show YAML: Usage in Automations</summary>
|
||||
|
||||
```yaml
|
||||
# ✅ Correct — language-independent
|
||||
condition:
|
||||
|
|
@ -100,6 +110,8 @@ condition:
|
|||
state: "Low" # "Niedrig" in German, "Lav" in Norwegian...
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
### Configuration
|
||||
|
||||
Rating thresholds can be adjusted in the options flow:
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
# Timing Sensors
|
||||
|
||||
> **Entity ID tip:** `<home_name>` is a placeholder for your Tibber home display name in Home Assistant. Entity IDs are derived from the displayed name (localized), so the exact slug may differ. **Can't find a sensor?** Use the **[Entity Reference (All Languages)](sensor-reference.md)** to search by name in your language.
|
||||
:::tip Entity ID tip
|
||||
`<home_name>` is a placeholder for your Tibber home display name in Home Assistant. Entity IDs are derived from the displayed name (localized), so the exact slug may differ. **Can't find a sensor?** Use the **[Entity Reference (All Languages)](sensor-reference.md)** to search by name in your language.
|
||||
:::
|
||||
|
||||
Timing sensors provide **real-time information about Best Price and Peak Price periods**: when they start, end, how long they last, and your progress through them.
|
||||
|
||||
|
|
@ -37,6 +39,9 @@ For each period type (Best Price and Peak Price):
|
|||
|
||||
### Show Countdown to Next Cheap Window
|
||||
|
||||
<details>
|
||||
<summary>Show YAML: Countdown to Next Cheap Window</summary>
|
||||
|
||||
```yaml
|
||||
type: custom:mushroom-entity-card
|
||||
entity: sensor.<home_name>_best_price_next_in_minutes
|
||||
|
|
@ -44,10 +49,12 @@ name: Next Cheap Window
|
|||
icon: mdi:clock-fast
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
### Display Period Progress Bar
|
||||
|
||||
<details>
|
||||
<summary>Show YAML: Bar card for period progress</summary>
|
||||
<summary>Show YAML: Display Period Progress Bar</summary>
|
||||
|
||||
```yaml
|
||||
type: custom:bar-card
|
||||
|
|
@ -72,7 +79,7 @@ severity:
|
|||
### Notify When Period Is Almost Over
|
||||
|
||||
<details>
|
||||
<summary>Show YAML: Automation — notify when best price period is ending</summary>
|
||||
<summary>Show YAML: Period Ending Notification</summary>
|
||||
|
||||
```yaml
|
||||
automation:
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
# Trend Sensors
|
||||
|
||||
> **Entity ID tip:** `<home_name>` is a placeholder for your Tibber home display name in Home Assistant. Entity IDs are derived from the displayed name (localized), so the exact slug may differ. **Can't find a sensor?** Use the **[Entity Reference (All Languages)](sensor-reference.md)** to search by name in your language.
|
||||
:::tip Entity ID tip
|
||||
`<home_name>` is a placeholder for your Tibber home display name in Home Assistant. Entity IDs are derived from the displayed name (localized), so the exact slug may differ. **Can't find a sensor?** Use the **[Entity Reference (All Languages)](sensor-reference.md)** to search by name in your language.
|
||||
:::
|
||||
|
||||
Trend sensors help you understand **whether to act now or wait**. The integration provides two complementary families:
|
||||
|
||||
|
|
@ -190,6 +192,9 @@ A **countdown timer** companion to the Next Price Trend Change sensor above. Ins
|
|||
|
||||
**Example automation:**
|
||||
|
||||
<details>
|
||||
<summary>Show YAML: Example automation</summary>
|
||||
|
||||
```yaml
|
||||
trigger:
|
||||
- platform: numeric_state
|
||||
|
|
@ -201,6 +206,8 @@ action:
|
|||
message: "Price trend is about to change direction!"
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
**Tip:** Use this sensor for "HOW LONG" and the Next Price Trend Change sensor (timestamp) for "WHEN".
|
||||
|
||||
---
|
||||
|
|
@ -231,6 +238,9 @@ A natural intuition is to treat trend states like a stock ticker:
|
|||
|
||||
For most appliances (dishwasher, washing machine, dryer), a single outlook sensor is enough:
|
||||
|
||||
<details>
|
||||
<summary>Show YAML: Basic Automation Pattern</summary>
|
||||
|
||||
```yaml
|
||||
# Example: Start dishwasher when prices are favorable
|
||||
trigger:
|
||||
|
|
@ -248,6 +258,8 @@ action:
|
|||
entity_id: switch.dishwasher
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
### Combining Multiple Windows
|
||||
|
||||
When short-term and long-term trends disagree, you get richer insight:
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
# Volatility Sensors
|
||||
|
||||
> **Entity ID tip:** `<home_name>` is a placeholder for your Tibber home display name in Home Assistant. Entity IDs are derived from the displayed name (localized), so the exact slug may differ. **Can't find a sensor?** Use the **[Entity Reference (All Languages)](sensor-reference.md)** to search by name in your language.
|
||||
:::tip Entity ID tip
|
||||
`<home_name>` is a placeholder for your Tibber home display name in Home Assistant. Entity IDs are derived from the displayed name (localized), so the exact slug may differ. **Can't find a sensor?** Use the **[Entity Reference (All Languages)](sensor-reference.md)** to search by name in your language.
|
||||
:::
|
||||
|
||||
Volatility sensors help you understand how much electricity prices fluctuate over a given period. Instead of just looking at the absolute price, they measure the **relative price variation**, which is a great indicator of whether it's a good day for price-based energy optimization.
|
||||
|
||||
|
|
@ -59,6 +61,9 @@ For automations, it is strongly recommended to use the `price_volatility` attrib
|
|||
|
||||
**Good Example (Robust Automation):**
|
||||
This automation triggers only if the volatility is classified as `high` or `very_high`, respecting your central settings and working independently of the system language.
|
||||
<details>
|
||||
<summary>Show YAML: Good Example (Robust Automation)</summary>
|
||||
|
||||
```yaml
|
||||
automation:
|
||||
- alias: "Enable battery optimization only on volatile days"
|
||||
|
|
@ -71,6 +76,8 @@ automation:
|
|||
entity_id: input_boolean.battery_optimization_enabled
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
### Avoid Hard-Coding Numeric Thresholds
|
||||
|
|
@ -81,6 +88,9 @@ You might be tempted to use the numeric `price_coefficient_variation_%` attribut
|
|||
|
||||
**Bad Example (Brittle Automation):**
|
||||
This automation uses a hard-coded value. If you later change the "High" threshold in the integration's options to 35%, this automation will not respect that change and might trigger at the wrong time.
|
||||
<details>
|
||||
<summary>Show YAML: Bad Example (Brittle Automation)</summary>
|
||||
|
||||
```yaml
|
||||
automation:
|
||||
- alias: "Brittle - Enable battery optimization"
|
||||
|
|
@ -97,4 +107,6 @@ automation:
|
|||
entity_id: input_boolean.battery_optimization_enabled
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
By following the "Good Example", your automations become simpler, more readable, and much easier to maintain.
|
||||
|
|
|
|||
|
|
@ -73,6 +73,9 @@ When reporting issues, debug logs help identify the problem quickly.
|
|||
|
||||
Add this to your `configuration.yaml`:
|
||||
|
||||
<details>
|
||||
<summary>Show YAML: configuration.yaml Logging</summary>
|
||||
|
||||
```yaml
|
||||
logger:
|
||||
default: warning
|
||||
|
|
@ -80,12 +83,17 @@ logger:
|
|||
custom_components.tibber_prices: debug
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
Restart Home Assistant for the change to take effect.
|
||||
|
||||
### Targeted Logging
|
||||
|
||||
For specific subsystems, you can enable logging selectively:
|
||||
|
||||
<details>
|
||||
<summary>Show YAML: Targeted Subsystem Logging</summary>
|
||||
|
||||
```yaml
|
||||
logger:
|
||||
default: warning
|
||||
|
|
@ -103,6 +111,8 @@ logger:
|
|||
custom_components.tibber_prices.sensor: debug
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
### Temporary Debug Logging (No Restart)
|
||||
|
||||
You can also enable debug logging temporarily from the HA UI:
|
||||
|
|
@ -110,10 +120,15 @@ You can also enable debug logging temporarily from the HA UI:
|
|||
1. Go to **Developer Tools → Services**
|
||||
2. Call service: `logger.set_level`
|
||||
3. Data:
|
||||
<details>
|
||||
<summary>Show YAML example (temporary logger.set_level payload)</summary>
|
||||
|
||||
```yaml
|
||||
custom_components.tibber_prices: debug
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
This resets when HA restarts.
|
||||
|
||||
### Downloading Diagnostics
|
||||
|
|
|
|||
|
|
@ -71,11 +71,19 @@ const sidebars: SidebarsConfig = {
|
|||
collapsible: true,
|
||||
collapsed: false,
|
||||
},
|
||||
{
|
||||
type: 'category',
|
||||
label: '⚡ Actions',
|
||||
link: { type: 'doc', id: 'actions' },
|
||||
items: ['actions', 'scheduling-actions', 'chart-actions', 'data-actions'],
|
||||
collapsible: true,
|
||||
collapsed: false,
|
||||
},
|
||||
{
|
||||
type: 'category',
|
||||
label: '📖 Reference',
|
||||
link: { type: 'doc', id: 'sensor-reference' },
|
||||
items: ['sensor-reference', 'actions'],
|
||||
items: ['sensor-reference'],
|
||||
collapsible: true,
|
||||
collapsed: false,
|
||||
},
|
||||
|
|
|
|||
258
tests/services/test_search_range.py
Normal file
258
tests/services/test_search_range.py
Normal file
|
|
@ -0,0 +1,258 @@
|
|||
"""
|
||||
Tests for resolve_search_range helper and negative offset support.
|
||||
|
||||
Verifies that services can search into the past using:
|
||||
- Negative search_start_day_offset / search_end_day_offset
|
||||
- Negative search_start_offset_minutes / search_end_offset_minutes
|
||||
- Explicit past search_start / search_end datetimes
|
||||
|
||||
Also validates schema boundaries for all 4 services.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import time as dt_time
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import pytest
|
||||
import voluptuous as vol
|
||||
|
||||
from custom_components.tibber_prices.services.find_cheapest_block import (
|
||||
_COMMON_BLOCK_SCHEMA,
|
||||
)
|
||||
from custom_components.tibber_prices.services.find_cheapest_hours import (
|
||||
_COMMON_HOURS_SCHEMA,
|
||||
)
|
||||
from custom_components.tibber_prices.services.helpers import (
|
||||
resolve_search_range,
|
||||
)
|
||||
|
||||
BERLIN = ZoneInfo("Europe/Berlin")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# resolve_search_range: Negative day offsets
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestResolveSearchRangeNegativeDayOffset:
|
||||
"""Test that negative day offsets correctly resolve to past dates."""
|
||||
|
||||
def test_negative_start_day_offset(self) -> None:
|
||||
"""Start yesterday at 06:00."""
|
||||
now = datetime(2026, 4, 11, 14, 30, tzinfo=BERLIN)
|
||||
call_data = {
|
||||
"search_start_time": dt_time(6, 0, 0),
|
||||
"search_start_day_offset": -1,
|
||||
}
|
||||
start, _end = resolve_search_range(call_data, now, BERLIN)
|
||||
# Should be yesterday 06:00
|
||||
assert start.day == 10
|
||||
assert start.hour == 6
|
||||
assert start.minute == 0
|
||||
|
||||
def test_negative_both_day_offsets(self) -> None:
|
||||
"""Full day in the past: yesterday 00:00 to yesterday 23:59."""
|
||||
now = datetime(2026, 4, 11, 14, 30, tzinfo=BERLIN)
|
||||
call_data = {
|
||||
"search_start_time": dt_time(0, 0, 0),
|
||||
"search_start_day_offset": -1,
|
||||
"search_end_time": dt_time(23, 59, 0),
|
||||
"search_end_day_offset": -1,
|
||||
}
|
||||
start, end = resolve_search_range(call_data, now, BERLIN)
|
||||
assert start.day == 10
|
||||
assert start.hour == 0
|
||||
assert end.day == 10
|
||||
assert end.hour == 23
|
||||
|
||||
def test_negative_7_day_offset(self) -> None:
|
||||
"""Start 7 days ago."""
|
||||
now = datetime(2026, 4, 11, 14, 30, tzinfo=BERLIN)
|
||||
call_data = {
|
||||
"search_start_time": dt_time(0, 0, 0),
|
||||
"search_start_day_offset": -7,
|
||||
"search_end_time": dt_time(23, 59, 0),
|
||||
"search_end_day_offset": -7,
|
||||
}
|
||||
start, end = resolve_search_range(call_data, now, BERLIN)
|
||||
assert start.day == 4
|
||||
assert end.day == 4
|
||||
|
||||
def test_cross_day_range_past_to_today(self) -> None:
|
||||
"""Start yesterday, end today."""
|
||||
now = datetime(2026, 4, 11, 14, 30, tzinfo=BERLIN)
|
||||
call_data = {
|
||||
"search_start_time": dt_time(18, 0, 0),
|
||||
"search_start_day_offset": -1,
|
||||
"search_end_time": dt_time(6, 0, 0),
|
||||
"search_end_day_offset": 0,
|
||||
}
|
||||
start, end = resolve_search_range(call_data, now, BERLIN)
|
||||
assert start.day == 10
|
||||
assert start.hour == 18
|
||||
assert end.day == 11
|
||||
assert end.hour == 6
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# resolve_search_range: Negative offset minutes
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestResolveSearchRangeNegativeOffsetMinutes:
|
||||
"""Test that negative offset minutes correctly resolve to past times."""
|
||||
|
||||
def test_negative_start_offset(self) -> None:
|
||||
"""Start 2 hours ago."""
|
||||
now = datetime(2026, 4, 11, 14, 30, tzinfo=BERLIN)
|
||||
call_data = {
|
||||
"search_start_offset_minutes": -120,
|
||||
"include_current_interval": True,
|
||||
}
|
||||
start, _end = resolve_search_range(call_data, now, BERLIN)
|
||||
# -120 min from 14:30 = 12:30, floored to 12:30
|
||||
assert start.hour == 12
|
||||
assert start.minute == 30
|
||||
|
||||
def test_negative_start_offset_floors_to_quarter(self) -> None:
|
||||
"""Negative offset gets floored to quarter-hour boundary."""
|
||||
now = datetime(2026, 4, 11, 14, 37, tzinfo=BERLIN)
|
||||
call_data = {
|
||||
"search_start_offset_minutes": -60,
|
||||
"include_current_interval": True,
|
||||
}
|
||||
start, _end = resolve_search_range(call_data, now, BERLIN)
|
||||
# -60 min from 14:37 = 13:37, floored to 13:30
|
||||
assert start.hour == 13
|
||||
assert start.minute == 30
|
||||
|
||||
def test_negative_end_offset(self) -> None:
|
||||
"""End 1 hour ago (fully historical range)."""
|
||||
now = datetime(2026, 4, 11, 14, 30, tzinfo=BERLIN)
|
||||
call_data = {
|
||||
"search_start_offset_minutes": -180,
|
||||
"search_end_offset_minutes": -60,
|
||||
"include_current_interval": True,
|
||||
}
|
||||
start, end = resolve_search_range(call_data, now, BERLIN)
|
||||
# Start: -180 min → 11:30, End: -60 min → 13:30
|
||||
assert start.hour == 11
|
||||
assert start.minute == 30
|
||||
assert end.hour == 13
|
||||
assert end.minute == 30
|
||||
|
||||
def test_large_negative_offset_crosses_day(self) -> None:
|
||||
"""Large negative offset crosses day boundary."""
|
||||
now = datetime(2026, 4, 11, 2, 0, tzinfo=BERLIN)
|
||||
call_data = {
|
||||
"search_start_offset_minutes": -180,
|
||||
"include_current_interval": True,
|
||||
}
|
||||
start, _end = resolve_search_range(call_data, now, BERLIN)
|
||||
# -180 min from 02:00 = 23:00 yesterday
|
||||
assert start.day == 10
|
||||
assert start.hour == 23
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Schema validation: day_offset boundaries
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestSchemaValidation:
|
||||
"""Verify that schemas accept negative offsets within bounds."""
|
||||
|
||||
def _validate_block_schema(self, data: dict) -> dict:
|
||||
"""Validate data through block schema."""
|
||||
schema = vol.Schema(_COMMON_BLOCK_SCHEMA)
|
||||
return schema(data)
|
||||
|
||||
def _validate_hours_schema(self, data: dict) -> dict:
|
||||
"""Validate data through hours schema."""
|
||||
schema = vol.Schema(_COMMON_HOURS_SCHEMA)
|
||||
return schema(data)
|
||||
|
||||
def test_block_schema_accepts_negative_day_offset(self) -> None:
|
||||
"""Block schema allows negative day offsets."""
|
||||
result = self._validate_block_schema(
|
||||
{
|
||||
"entry_id": "test",
|
||||
"duration": timedelta(hours=1),
|
||||
"search_start_day_offset": -3,
|
||||
"search_end_day_offset": -1,
|
||||
}
|
||||
)
|
||||
assert result["search_start_day_offset"] == -3
|
||||
assert result["search_end_day_offset"] == -1
|
||||
|
||||
def test_block_schema_accepts_negative_offset_minutes(self) -> None:
|
||||
"""Block schema allows negative offset minutes."""
|
||||
result = self._validate_block_schema(
|
||||
{
|
||||
"entry_id": "test",
|
||||
"duration": timedelta(hours=1),
|
||||
"search_start_offset_minutes": -1440,
|
||||
"search_end_offset_minutes": -60,
|
||||
}
|
||||
)
|
||||
assert result["search_start_offset_minutes"] == -1440
|
||||
assert result["search_end_offset_minutes"] == -60
|
||||
|
||||
def test_block_schema_rejects_out_of_bounds_day_offset(self) -> None:
|
||||
"""Block schema rejects day offset < -7."""
|
||||
with pytest.raises(vol.Invalid):
|
||||
self._validate_block_schema(
|
||||
{
|
||||
"entry_id": "test",
|
||||
"duration": timedelta(hours=1),
|
||||
"search_start_day_offset": -8,
|
||||
}
|
||||
)
|
||||
|
||||
def test_block_schema_max_day_offset_still_2(self) -> None:
|
||||
"""Block schema still limits forward to +2."""
|
||||
with pytest.raises(vol.Invalid):
|
||||
self._validate_block_schema(
|
||||
{
|
||||
"entry_id": "test",
|
||||
"duration": timedelta(hours=1),
|
||||
"search_start_day_offset": 3,
|
||||
}
|
||||
)
|
||||
|
||||
def test_hours_schema_accepts_negative_day_offset(self) -> None:
|
||||
"""Hours schema allows negative day offsets."""
|
||||
result = self._validate_hours_schema(
|
||||
{
|
||||
"entry_id": "test",
|
||||
"duration": timedelta(hours=2),
|
||||
"search_start_day_offset": -7,
|
||||
"search_end_day_offset": -5,
|
||||
}
|
||||
)
|
||||
assert result["search_start_day_offset"] == -7
|
||||
|
||||
def test_hours_schema_accepts_negative_offset_minutes(self) -> None:
|
||||
"""Hours schema allows negative offset minutes."""
|
||||
result = self._validate_hours_schema(
|
||||
{
|
||||
"entry_id": "test",
|
||||
"duration": timedelta(hours=2),
|
||||
"search_start_offset_minutes": -10080,
|
||||
"search_end_offset_minutes": -60,
|
||||
}
|
||||
)
|
||||
assert result["search_start_offset_minutes"] == -10080
|
||||
|
||||
def test_hours_schema_rejects_out_of_bounds_offset_minutes(self) -> None:
|
||||
"""Hours schema rejects offset minutes outside ±10080."""
|
||||
with pytest.raises(vol.Invalid):
|
||||
self._validate_hours_schema(
|
||||
{
|
||||
"entry_id": "test",
|
||||
"duration": timedelta(hours=2),
|
||||
"search_start_offset_minutes": -10081,
|
||||
}
|
||||
)
|
||||
602
tests/test_price_window.py
Normal file
602
tests/test_price_window.py
Normal file
|
|
@ -0,0 +1,602 @@
|
|||
"""
|
||||
Tests for price window algorithms.
|
||||
|
||||
Tests the pure algorithm functions in utils/price_window.py:
|
||||
- find_cheapest_contiguous_window (sliding window)
|
||||
- find_cheapest_n_intervals (cheapest N picks with optional min-segment)
|
||||
- group_intervals_into_segments
|
||||
- calculate_window_statistics
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from custom_components.tibber_prices.utils.price_window import (
|
||||
calculate_window_statistics,
|
||||
find_cheapest_contiguous_window,
|
||||
find_cheapest_n_intervals,
|
||||
group_intervals_into_segments,
|
||||
)
|
||||
|
||||
# =============================================================================
|
||||
# Test Helpers
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def _make_intervals(
|
||||
prices: list[float],
|
||||
start: datetime | None = None,
|
||||
gap_after: set[int] | None = None,
|
||||
) -> list[dict]:
|
||||
"""
|
||||
Create interval dicts from a list of prices.
|
||||
|
||||
Args:
|
||||
prices: List of price values (one per 15-min interval).
|
||||
start: Start datetime (defaults to 2026-04-11T00:00:00+02:00).
|
||||
gap_after: Set of indices after which to insert a 15-min gap
|
||||
(making non-contiguous intervals).
|
||||
|
||||
"""
|
||||
if start is None:
|
||||
start = datetime(2026, 4, 11, 0, 0, tzinfo=UTC)
|
||||
|
||||
intervals = []
|
||||
current = start
|
||||
for i, price in enumerate(prices):
|
||||
intervals.append(
|
||||
{
|
||||
"startsAt": current.isoformat(),
|
||||
"total": price,
|
||||
"energy": price * 0.8,
|
||||
"tax": price * 0.2,
|
||||
"level": "NORMAL",
|
||||
}
|
||||
)
|
||||
skip = 2 if gap_after and i in gap_after else 1
|
||||
current += timedelta(minutes=15 * skip)
|
||||
|
||||
return intervals
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# find_cheapest_contiguous_window
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestFindCheapestContiguousWindow:
|
||||
"""Tests for the sliding window algorithm."""
|
||||
|
||||
def test_empty_intervals(self) -> None:
|
||||
"""Return None for empty input."""
|
||||
assert find_cheapest_contiguous_window([], 4) is None
|
||||
|
||||
def test_duration_exceeds_available(self) -> None:
|
||||
"""Return None when not enough intervals."""
|
||||
intervals = _make_intervals([10.0, 20.0, 15.0])
|
||||
assert find_cheapest_contiguous_window(intervals, 4) is None
|
||||
|
||||
def test_zero_duration(self) -> None:
|
||||
"""Return None for zero duration."""
|
||||
intervals = _make_intervals([10.0, 20.0])
|
||||
assert find_cheapest_contiguous_window(intervals, 0) is None
|
||||
|
||||
def test_exact_fit(self) -> None:
|
||||
"""Window equals all available intervals."""
|
||||
prices = [10.0, 20.0, 15.0, 12.0]
|
||||
intervals = _make_intervals(prices)
|
||||
result = find_cheapest_contiguous_window(intervals, 4)
|
||||
assert result is not None
|
||||
assert len(result["intervals"]) == 4
|
||||
|
||||
def test_single_interval(self) -> None:
|
||||
"""Window of 1 picks the cheapest single interval."""
|
||||
prices = [30.0, 10.0, 20.0, 40.0]
|
||||
intervals = _make_intervals(prices)
|
||||
result = find_cheapest_contiguous_window(intervals, 1)
|
||||
assert result is not None
|
||||
assert len(result["intervals"]) == 1
|
||||
assert result["intervals"][0]["total"] == 10.0
|
||||
|
||||
def test_u_shaped_curve(self) -> None:
|
||||
"""Finds cheap window in center of U-shaped price curve."""
|
||||
# U-shape: expensive morning, cheap midday, expensive evening
|
||||
prices = [30.0, 25.0, 15.0, 10.0, 8.0, 9.0, 12.0, 20.0, 28.0, 35.0]
|
||||
intervals = _make_intervals(prices)
|
||||
result = find_cheapest_contiguous_window(intervals, 4)
|
||||
assert result is not None
|
||||
# Should be intervals 3-6: [10.0, 8.0, 9.0, 12.0] = sum 39.0
|
||||
selected_prices = [iv["total"] for iv in result["intervals"]]
|
||||
assert selected_prices == [10.0, 8.0, 9.0, 12.0]
|
||||
|
||||
def test_v_shaped_curve(self) -> None:
|
||||
"""Finds cheapest block on V-shaped day (classic Issue #108 scenario)."""
|
||||
# V-shape: expensive → cheap minimum → expensive
|
||||
prices = [25.0, 20.0, 15.0, 10.0, 5.0, 10.0, 15.0, 20.0]
|
||||
intervals = _make_intervals(prices)
|
||||
# 4-interval window: cheapest is centered on minimum
|
||||
result = find_cheapest_contiguous_window(intervals, 4)
|
||||
assert result is not None
|
||||
selected_prices = [iv["total"] for iv in result["intervals"]]
|
||||
# [15.0, 10.0, 5.0, 10.0] = 40.0 or [10.0, 5.0, 10.0, 15.0] = 40.0
|
||||
assert sum(selected_prices) == 40.0
|
||||
|
||||
def test_flat_prices(self) -> None:
|
||||
"""All prices equal: picks first window."""
|
||||
prices = [10.0] * 8
|
||||
intervals = _make_intervals(prices)
|
||||
result = find_cheapest_contiguous_window(intervals, 4)
|
||||
assert result is not None
|
||||
# First window (index 0)
|
||||
assert result["intervals"][0]["startsAt"] == intervals[0]["startsAt"]
|
||||
|
||||
def test_cheapest_at_end(self) -> None:
|
||||
"""Cheapest window is the last N intervals."""
|
||||
prices = [30.0, 25.0, 20.0, 15.0, 10.0, 5.0, 3.0, 2.0]
|
||||
intervals = _make_intervals(prices)
|
||||
result = find_cheapest_contiguous_window(intervals, 4)
|
||||
assert result is not None
|
||||
selected_prices = [iv["total"] for iv in result["intervals"]]
|
||||
assert selected_prices == [10.0, 5.0, 3.0, 2.0]
|
||||
|
||||
def test_cheapest_at_start(self) -> None:
|
||||
"""Cheapest window is the first N intervals."""
|
||||
prices = [2.0, 3.0, 5.0, 10.0, 15.0, 20.0, 25.0, 30.0]
|
||||
intervals = _make_intervals(prices)
|
||||
result = find_cheapest_contiguous_window(intervals, 4)
|
||||
assert result is not None
|
||||
selected_prices = [iv["total"] for iv in result["intervals"]]
|
||||
assert selected_prices == [2.0, 3.0, 5.0, 10.0]
|
||||
|
||||
def test_negative_prices(self) -> None:
|
||||
"""Handles negative prices (renewable surplus)."""
|
||||
prices = [5.0, 3.0, -1.0, -3.0, -2.0, 1.0, 4.0, 8.0]
|
||||
intervals = _make_intervals(prices)
|
||||
result = find_cheapest_contiguous_window(intervals, 3)
|
||||
assert result is not None
|
||||
selected_prices = [iv["total"] for iv in result["intervals"]]
|
||||
# [-1.0, -3.0, -2.0] = -6.0 is cheapest 3-block
|
||||
assert selected_prices == [-1.0, -3.0, -2.0]
|
||||
|
||||
def test_midnight_crossing(self) -> None:
|
||||
"""Window can span midnight."""
|
||||
# 8 intervals starting at 22:00 → crossing midnight
|
||||
start = datetime(2026, 4, 11, 22, 0, tzinfo=UTC)
|
||||
prices = [20.0, 15.0, 10.0, 5.0, 3.0, 2.0, 8.0, 12.0]
|
||||
intervals = _make_intervals(prices, start=start)
|
||||
result = find_cheapest_contiguous_window(intervals, 4)
|
||||
assert result is not None
|
||||
selected_prices = [iv["total"] for iv in result["intervals"]]
|
||||
assert selected_prices == [5.0, 3.0, 2.0, 8.0]
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# find_cheapest_n_intervals
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestFindCheapestNIntervals:
|
||||
"""Tests for the cheapest-N-picks algorithm."""
|
||||
|
||||
def test_empty_intervals(self) -> None:
|
||||
"""Return None for empty input."""
|
||||
assert find_cheapest_n_intervals([], 4) is None
|
||||
|
||||
def test_count_exceeds_available(self) -> None:
|
||||
"""Return None when not enough intervals."""
|
||||
intervals = _make_intervals([10.0, 20.0, 15.0])
|
||||
assert find_cheapest_n_intervals(intervals, 4) is None
|
||||
|
||||
def test_zero_count(self) -> None:
|
||||
"""Return None for zero count."""
|
||||
intervals = _make_intervals([10.0])
|
||||
assert find_cheapest_n_intervals(intervals, 0) is None
|
||||
|
||||
def test_picks_cheapest(self) -> None:
|
||||
"""Picks the N cheapest intervals regardless of position."""
|
||||
prices = [30.0, 10.0, 25.0, 5.0, 20.0, 8.0, 35.0, 15.0]
|
||||
intervals = _make_intervals(prices)
|
||||
result = find_cheapest_n_intervals(intervals, 3)
|
||||
assert result is not None
|
||||
selected_prices = sorted(iv["total"] for iv in result["intervals"])
|
||||
assert selected_prices == [5.0, 8.0, 10.0]
|
||||
|
||||
def test_chronological_order(self) -> None:
|
||||
"""Result intervals are sorted chronologically."""
|
||||
prices = [30.0, 10.0, 25.0, 5.0, 20.0, 8.0]
|
||||
intervals = _make_intervals(prices)
|
||||
result = find_cheapest_n_intervals(intervals, 3)
|
||||
assert result is not None
|
||||
starts = [iv["startsAt"] for iv in result["intervals"]]
|
||||
assert starts == sorted(starts)
|
||||
|
||||
def test_segments_grouped(self) -> None:
|
||||
"""Result contains segments grouping contiguous intervals."""
|
||||
# Cheapest 4 from: 30, 10, 8, 5, 20, 3, 2, 35
|
||||
# Picks: 2(idx6), 3(idx5), 5(idx3), 8(idx2)
|
||||
prices = [30.0, 10.0, 8.0, 5.0, 20.0, 3.0, 2.0, 35.0]
|
||||
intervals = _make_intervals(prices)
|
||||
result = find_cheapest_n_intervals(intervals, 4)
|
||||
assert result is not None
|
||||
assert "segments" in result
|
||||
assert len(result["segments"]) >= 1
|
||||
|
||||
def test_single_contiguous_segment(self) -> None:
|
||||
"""All picked intervals form one segment."""
|
||||
# Cheapest 3: indices 2,3,4 → [5.0, 3.0, 4.0] all adjacent
|
||||
prices = [20.0, 15.0, 5.0, 3.0, 4.0, 25.0, 30.0]
|
||||
intervals = _make_intervals(prices)
|
||||
result = find_cheapest_n_intervals(intervals, 3)
|
||||
assert result is not None
|
||||
assert len(result["segments"]) == 1
|
||||
assert result["segments"][0]["interval_count"] == 3
|
||||
|
||||
def test_min_segment_basic(self) -> None:
|
||||
"""With min_segment=2, single-interval segments are excluded."""
|
||||
# Prices: 10, 20, 30, 5, 40, 8, 7, 35
|
||||
# Without constraint: picks 5(idx3), 7(idx6), 8(idx5) → 3 isolated singles
|
||||
# With min_segment=2: must form segments ≥2 intervals
|
||||
prices = [10.0, 20.0, 30.0, 5.0, 40.0, 8.0, 7.0, 35.0]
|
||||
intervals = _make_intervals(prices)
|
||||
result = find_cheapest_n_intervals(intervals, 3, min_segment_intervals=2)
|
||||
assert result is not None
|
||||
# All segments should be ≥ 2 intervals
|
||||
for seg in result["segments"]:
|
||||
assert seg["interval_count"] >= 2
|
||||
|
||||
def test_min_segment_forces_different_selection(self) -> None:
|
||||
"""Min segment constraint changes the selection vs. no constraint."""
|
||||
prices = [10.0, 50.0, 50.0, 5.0, 50.0, 50.0, 8.0, 50.0]
|
||||
intervals = _make_intervals(prices)
|
||||
|
||||
# Without constraint: picks indices 0(10), 3(5), 6(8)
|
||||
result_no_constraint = find_cheapest_n_intervals(intervals, 3, min_segment_intervals=1)
|
||||
assert result_no_constraint is not None
|
||||
prices_no = sorted(iv["total"] for iv in result_no_constraint["intervals"])
|
||||
assert prices_no == [5.0, 8.0, 10.0]
|
||||
|
||||
# With constraint (min 2): those are all isolated → must find alternatives
|
||||
result_constrained = find_cheapest_n_intervals(intervals, 3, min_segment_intervals=2)
|
||||
assert result_constrained is not None
|
||||
# Selection will be different
|
||||
prices_constrained = sorted(iv["total"] for iv in result_constrained["intervals"])
|
||||
assert prices_constrained != prices_no
|
||||
|
||||
def test_negative_prices(self) -> None:
|
||||
"""Handles negative prices correctly."""
|
||||
prices = [5.0, -3.0, 10.0, -5.0, 8.0, -1.0]
|
||||
intervals = _make_intervals(prices)
|
||||
result = find_cheapest_n_intervals(intervals, 3)
|
||||
assert result is not None
|
||||
selected_prices = sorted(iv["total"] for iv in result["intervals"])
|
||||
assert selected_prices == [-5.0, -3.0, -1.0]
|
||||
|
||||
def test_exact_fit(self) -> None:
|
||||
"""Count equals available intervals."""
|
||||
prices = [10.0, 20.0, 15.0]
|
||||
intervals = _make_intervals(prices)
|
||||
result = find_cheapest_n_intervals(intervals, 3)
|
||||
assert result is not None
|
||||
assert len(result["intervals"]) == 3
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# group_intervals_into_segments
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestGroupIntervalsIntoSegments:
|
||||
"""Tests for segment grouping."""
|
||||
|
||||
def test_empty(self) -> None:
|
||||
"""Empty input returns empty list."""
|
||||
assert group_intervals_into_segments([]) == []
|
||||
|
||||
def test_single_interval(self) -> None:
|
||||
"""Single interval becomes one segment."""
|
||||
intervals = _make_intervals([10.0])
|
||||
segments = group_intervals_into_segments(intervals)
|
||||
assert len(segments) == 1
|
||||
assert segments[0]["interval_count"] == 1
|
||||
assert segments[0]["duration_minutes"] == 15
|
||||
|
||||
def test_all_contiguous(self) -> None:
|
||||
"""All contiguous intervals form one segment."""
|
||||
intervals = _make_intervals([10.0, 20.0, 15.0, 12.0])
|
||||
segments = group_intervals_into_segments(intervals)
|
||||
assert len(segments) == 1
|
||||
assert segments[0]["interval_count"] == 4
|
||||
assert segments[0]["duration_minutes"] == 60
|
||||
|
||||
def test_gap_creates_segments(self) -> None:
|
||||
"""A gap creates separate segments."""
|
||||
# Gap after index 1 (30-min gap instead of 15-min)
|
||||
intervals = _make_intervals([10.0, 20.0, 15.0, 12.0], gap_after={1})
|
||||
segments = group_intervals_into_segments(intervals)
|
||||
assert len(segments) == 2
|
||||
assert segments[0]["interval_count"] == 2
|
||||
assert segments[1]["interval_count"] == 2
|
||||
|
||||
def test_multiple_gaps(self) -> None:
|
||||
"""Multiple gaps create multiple segments."""
|
||||
intervals = _make_intervals(
|
||||
[10.0, 20.0, 15.0, 12.0, 8.0],
|
||||
gap_after={0, 2},
|
||||
)
|
||||
segments = group_intervals_into_segments(intervals)
|
||||
assert len(segments) == 3
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# calculate_window_statistics
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestCalculateWindowStatistics:
|
||||
"""Tests for price statistics calculation."""
|
||||
|
||||
def test_empty(self) -> None:
|
||||
"""Empty input returns all None."""
|
||||
stats = calculate_window_statistics([])
|
||||
assert stats["price_mean"] is None
|
||||
assert stats["price_median"] is None
|
||||
assert stats["estimated_total_cost"] is None
|
||||
|
||||
def test_basic_stats(self) -> None:
|
||||
"""Correct mean, median, min, max, spread."""
|
||||
intervals = _make_intervals([10.0, 20.0, 30.0, 40.0])
|
||||
stats = calculate_window_statistics(intervals)
|
||||
assert stats["price_mean"] == 25.0
|
||||
assert stats["price_median"] == 25.0
|
||||
assert stats["price_min"] == 10.0
|
||||
assert stats["price_max"] == 40.0
|
||||
assert stats["price_spread"] == 30.0
|
||||
# 4 intervals x 15min = 1h, cost = sum(price x 0.25h) = (10+20+30+40) x 0.25 = 25.0
|
||||
assert stats["estimated_total_cost"] == 25.0
|
||||
|
||||
def test_unit_factor(self) -> None:
|
||||
"""Unit factor multiplies all values."""
|
||||
intervals = _make_intervals([0.10, 0.20, 0.30])
|
||||
stats = calculate_window_statistics(intervals, unit_factor=100)
|
||||
assert stats["price_mean"] == 20.0
|
||||
assert stats["price_min"] == 10.0
|
||||
assert stats["price_max"] == 30.0
|
||||
# 3 intervals x 15min, prices in subunit: (10+20+30) x 0.25 = 15.0
|
||||
assert stats["estimated_total_cost"] == 15.0
|
||||
|
||||
def test_single_interval(self) -> None:
|
||||
"""Single interval: mean=median=min=max, spread=0."""
|
||||
intervals = _make_intervals([15.0])
|
||||
stats = calculate_window_statistics(intervals)
|
||||
assert stats["price_mean"] == 15.0
|
||||
assert stats["price_median"] == 15.0
|
||||
assert stats["price_min"] == 15.0
|
||||
assert stats["price_max"] == 15.0
|
||||
assert stats["price_spread"] == 0.0
|
||||
# 1 interval x 0.25h x 15.0 = 3.75
|
||||
assert stats["estimated_total_cost"] == 3.75
|
||||
|
||||
def test_negative_prices(self) -> None:
|
||||
"""Handles negative prices."""
|
||||
intervals = _make_intervals([-10.0, -5.0, -20.0])
|
||||
stats = calculate_window_statistics(intervals)
|
||||
assert stats["price_min"] == -20.0
|
||||
assert stats["price_max"] == -5.0
|
||||
assert stats["price_spread"] == 15.0
|
||||
# 3 intervals x 0.25h: (-10+-5+-20) x 0.25 = -8.75
|
||||
assert stats["estimated_total_cost"] == -8.75
|
||||
|
||||
def test_rounding(self) -> None:
|
||||
"""Results are rounded to specified decimals."""
|
||||
intervals = _make_intervals([1.0 / 3.0, 2.0 / 3.0])
|
||||
stats = calculate_window_statistics(intervals, round_decimals=2)
|
||||
assert stats["price_mean"] == 0.5
|
||||
assert stats["price_min"] == 0.33
|
||||
assert stats["price_max"] == 0.67
|
||||
# (0.333...+0.666...) x 0.25 = 0.25
|
||||
assert stats["estimated_total_cost"] == 0.25
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Reverse mode (find most expensive)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestFindMostExpensiveContiguousWindow:
|
||||
"""Tests for the sliding window algorithm with reverse=True."""
|
||||
|
||||
def test_finds_most_expensive_block(self) -> None:
|
||||
"""Reverse mode finds the most expensive contiguous window."""
|
||||
prices = [10.0, 20.0, 30.0, 40.0, 5.0, 3.0, 2.0, 1.0]
|
||||
intervals = _make_intervals(prices)
|
||||
result = find_cheapest_contiguous_window(intervals, 4, reverse=True)
|
||||
assert result is not None
|
||||
selected_prices = [iv["total"] for iv in result["intervals"]]
|
||||
assert selected_prices == [10.0, 20.0, 30.0, 40.0]
|
||||
|
||||
def test_most_expensive_at_end(self) -> None:
|
||||
"""Most expensive window at the end."""
|
||||
prices = [1.0, 2.0, 3.0, 4.0, 10.0, 20.0, 30.0, 40.0]
|
||||
intervals = _make_intervals(prices)
|
||||
result = find_cheapest_contiguous_window(intervals, 4, reverse=True)
|
||||
assert result is not None
|
||||
selected_prices = [iv["total"] for iv in result["intervals"]]
|
||||
assert selected_prices == [10.0, 20.0, 30.0, 40.0]
|
||||
|
||||
def test_reverse_single_interval(self) -> None:
|
||||
"""Reverse picks the most expensive single interval."""
|
||||
prices = [5.0, 40.0, 10.0, 30.0]
|
||||
intervals = _make_intervals(prices)
|
||||
result = find_cheapest_contiguous_window(intervals, 1, reverse=True)
|
||||
assert result is not None
|
||||
assert result["intervals"][0]["total"] == 40.0
|
||||
|
||||
def test_reverse_empty_returns_none(self) -> None:
|
||||
"""Edge case: empty input."""
|
||||
assert find_cheapest_contiguous_window([], 4, reverse=True) is None
|
||||
|
||||
def test_reverse_vs_forward_different(self) -> None:
|
||||
"""Reverse and forward give different results on asymmetric data."""
|
||||
prices = [5.0, 10.0, 30.0, 25.0, 3.0, 2.0]
|
||||
intervals = _make_intervals(prices)
|
||||
cheapest = find_cheapest_contiguous_window(intervals, 2)
|
||||
most_expensive = find_cheapest_contiguous_window(intervals, 2, reverse=True)
|
||||
assert cheapest is not None
|
||||
assert most_expensive is not None
|
||||
cheap_sum = sum(iv["total"] for iv in cheapest["intervals"])
|
||||
exp_sum = sum(iv["total"] for iv in most_expensive["intervals"])
|
||||
assert exp_sum > cheap_sum
|
||||
|
||||
|
||||
class TestFindMostExpensiveNIntervals:
|
||||
"""Tests for the cheapest-N-picks algorithm with reverse=True."""
|
||||
|
||||
def test_picks_most_expensive(self) -> None:
|
||||
"""Reverse picks the N most expensive intervals."""
|
||||
prices = [30.0, 10.0, 25.0, 5.0, 20.0, 8.0, 35.0, 15.0]
|
||||
intervals = _make_intervals(prices)
|
||||
result = find_cheapest_n_intervals(intervals, 3, reverse=True)
|
||||
assert result is not None
|
||||
selected_prices = sorted((iv["total"] for iv in result["intervals"]), reverse=True)
|
||||
assert selected_prices == [35.0, 30.0, 25.0]
|
||||
|
||||
def test_reverse_chronological_order(self) -> None:
|
||||
"""Reverse result intervals are still sorted chronologically."""
|
||||
prices = [30.0, 10.0, 25.0, 5.0, 20.0, 8.0, 35.0, 15.0]
|
||||
intervals = _make_intervals(prices)
|
||||
result = find_cheapest_n_intervals(intervals, 3, reverse=True)
|
||||
assert result is not None
|
||||
starts = [iv["startsAt"] for iv in result["intervals"]]
|
||||
assert starts == sorted(starts)
|
||||
|
||||
def test_reverse_min_segment(self) -> None:
|
||||
"""Reverse with min_segment constraint picks expensive segments."""
|
||||
prices = [5.0, 30.0, 35.0, 3.0, 2.0, 40.0, 38.0, 1.0]
|
||||
intervals = _make_intervals(prices)
|
||||
result = find_cheapest_n_intervals(intervals, 4, min_segment_intervals=2, reverse=True)
|
||||
assert result is not None
|
||||
for seg in result["segments"]:
|
||||
assert seg["interval_count"] >= 2
|
||||
|
||||
def test_reverse_empty_returns_none(self) -> None:
|
||||
"""Edge case: empty input."""
|
||||
assert find_cheapest_n_intervals([], 4, reverse=True) is None
|
||||
|
||||
def test_reverse_vs_forward_different(self) -> None:
|
||||
"""Reverse and forward produce different sets."""
|
||||
prices = [5.0, 10.0, 30.0, 25.0, 3.0, 2.0, 40.0, 15.0]
|
||||
intervals = _make_intervals(prices)
|
||||
cheapest = find_cheapest_n_intervals(intervals, 3)
|
||||
most_expensive = find_cheapest_n_intervals(intervals, 3, reverse=True)
|
||||
assert cheapest is not None
|
||||
assert most_expensive is not None
|
||||
cheap_prices = sorted(iv["total"] for iv in cheapest["intervals"])
|
||||
exp_prices = sorted(iv["total"] for iv in most_expensive["intervals"])
|
||||
assert cheap_prices != exp_prices
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Price Comparison (Cheapest vs Most Expensive)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestPriceComparison:
|
||||
"""Tests for price comparison between cheapest and most expensive windows."""
|
||||
|
||||
def test_contiguous_window_spread(self) -> None:
|
||||
"""Price difference between cheapest and most expensive contiguous windows."""
|
||||
# Prices: clear cheap period (0.05) and clear expensive period (0.30)
|
||||
prices = [0.05, 0.05, 0.05, 0.05, 0.20, 0.20, 0.30, 0.30, 0.30, 0.30]
|
||||
intervals = _make_intervals(prices)
|
||||
|
||||
cheapest = find_cheapest_contiguous_window(intervals, 4, reverse=False)
|
||||
most_expensive = find_cheapest_contiguous_window(intervals, 4, reverse=True)
|
||||
|
||||
assert cheapest is not None
|
||||
assert most_expensive is not None
|
||||
|
||||
cheap_stats = calculate_window_statistics(cheapest["intervals"])
|
||||
expensive_stats = calculate_window_statistics(most_expensive["intervals"])
|
||||
|
||||
assert cheap_stats["price_mean"] is not None
|
||||
assert expensive_stats["price_mean"] is not None
|
||||
|
||||
spread = round(expensive_stats["price_mean"] - cheap_stats["price_mean"], 4)
|
||||
# Mean of [0.30, 0.30, 0.30, 0.30] - mean of [0.05, 0.05, 0.05, 0.05] = 0.25
|
||||
assert spread > 0
|
||||
assert abs(spread - 0.25) < 0.001
|
||||
|
||||
def test_spread_symmetric(self) -> None:
|
||||
"""Price difference is the same regardless of which direction we compute from."""
|
||||
prices = [0.10, 0.10, 0.40, 0.40, 0.15, 0.15, 0.35, 0.35]
|
||||
intervals = _make_intervals(prices)
|
||||
|
||||
cheapest = find_cheapest_contiguous_window(intervals, 2, reverse=False)
|
||||
most_expensive = find_cheapest_contiguous_window(intervals, 2, reverse=True)
|
||||
|
||||
assert cheapest is not None
|
||||
assert most_expensive is not None
|
||||
|
||||
cheap_stats = calculate_window_statistics(cheapest["intervals"])
|
||||
expensive_stats = calculate_window_statistics(most_expensive["intervals"])
|
||||
|
||||
spread_cheap_to_exp = expensive_stats["price_mean"] - cheap_stats["price_mean"]
|
||||
spread_exp_to_cheap = cheap_stats["price_mean"] - expensive_stats["price_mean"]
|
||||
|
||||
assert abs(spread_cheap_to_exp + spread_exp_to_cheap) < 0.0001
|
||||
|
||||
def test_n_intervals_spread(self) -> None:
|
||||
"""Price difference between cheapest and most expensive N picks."""
|
||||
prices = [0.02, 0.50, 0.03, 0.45, 0.01, 0.48, 0.04, 0.42]
|
||||
intervals = _make_intervals(prices)
|
||||
|
||||
cheapest = find_cheapest_n_intervals(intervals, 3, reverse=False)
|
||||
most_expensive = find_cheapest_n_intervals(intervals, 3, reverse=True)
|
||||
|
||||
assert cheapest is not None
|
||||
assert most_expensive is not None
|
||||
|
||||
cheap_stats = calculate_window_statistics(cheapest["intervals"])
|
||||
expensive_stats = calculate_window_statistics(most_expensive["intervals"])
|
||||
|
||||
assert cheap_stats["price_mean"] is not None
|
||||
assert expensive_stats["price_mean"] is not None
|
||||
|
||||
# Cheapest 3: [0.01, 0.02, 0.03] → mean 0.02
|
||||
# Most expensive 3: [0.50, 0.48, 0.45] → mean ~0.4767
|
||||
spread = expensive_stats["price_mean"] - cheap_stats["price_mean"]
|
||||
assert spread > 0.4
|
||||
|
||||
def test_flat_prices_zero_spread(self) -> None:
|
||||
"""Flat prices produce zero price difference."""
|
||||
prices = [0.25, 0.25, 0.25, 0.25, 0.25, 0.25]
|
||||
intervals = _make_intervals(prices)
|
||||
|
||||
cheapest = find_cheapest_contiguous_window(intervals, 3, reverse=False)
|
||||
most_expensive = find_cheapest_contiguous_window(intervals, 3, reverse=True)
|
||||
|
||||
assert cheapest is not None
|
||||
assert most_expensive is not None
|
||||
|
||||
cheap_stats = calculate_window_statistics(cheapest["intervals"])
|
||||
expensive_stats = calculate_window_statistics(most_expensive["intervals"])
|
||||
|
||||
spread = expensive_stats["price_mean"] - cheap_stats["price_mean"]
|
||||
assert abs(spread) < 0.0001
|
||||
|
||||
def test_single_interval_no_spread(self) -> None:
|
||||
"""With only 1 interval and duration=1, cheapest==most expensive (no difference)."""
|
||||
intervals = _make_intervals([0.30])
|
||||
|
||||
cheapest = find_cheapest_contiguous_window(intervals, 1, reverse=False)
|
||||
most_expensive = find_cheapest_contiguous_window(intervals, 1, reverse=True)
|
||||
|
||||
assert cheapest is not None
|
||||
assert most_expensive is not None
|
||||
|
||||
cheap_stats = calculate_window_statistics(cheapest["intervals"])
|
||||
expensive_stats = calculate_window_statistics(most_expensive["intervals"])
|
||||
|
||||
spread = expensive_stats["price_mean"] - cheap_stats["price_mean"]
|
||||
assert abs(spread) < 0.0001
|
||||
Loading…
Reference in a new issue