mirror of
https://github.com/jpawlowski/hass.tibber_prices.git
synced 2026-07-28 17:46: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": [
|
"python.testing.pytestArgs": [
|
||||||
"--no-cov"
|
"--no-cov"
|
||||||
],
|
],
|
||||||
|
"[json]": {
|
||||||
|
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||||
|
"editor.tabSize": 2
|
||||||
|
},
|
||||||
|
"[jsonc]": {
|
||||||
|
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||||
|
"editor.tabSize": 2
|
||||||
|
},
|
||||||
"[python]": {
|
"[python]": {
|
||||||
"editor.defaultFormatter": "charliermarsh.ruff",
|
"editor.defaultFormatter": "charliermarsh.ruff",
|
||||||
"editor.formatOnSave": true,
|
"editor.formatOnSave": true,
|
||||||
|
|
|
||||||
|
|
@ -13,5 +13,8 @@ indent_size = 4
|
||||||
# Python style aligns with Black
|
# Python style aligns with Black
|
||||||
indent_size = 4
|
indent_size = 4
|
||||||
|
|
||||||
|
[*.json]
|
||||||
|
indent_size = 2
|
||||||
|
|
||||||
[*.md]
|
[*.md]
|
||||||
trim_trailing_whitespace = false
|
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"]
|
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:
|
def add_relaxation_attributes(attributes: dict, current_period: dict) -> None:
|
||||||
"""
|
"""
|
||||||
Add relaxation information attributes (priority 6).
|
Add relaxation information attributes (priority 6).
|
||||||
|
|
@ -412,6 +458,9 @@ def build_final_attributes_simple(
|
||||||
# 5. Detail information
|
# 5. Detail information
|
||||||
add_detail_attributes(attributes, current_period)
|
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)
|
# 6. Relaxation information (only if current period was relaxed)
|
||||||
add_relaxation_attributes(attributes, current_period)
|
add_relaxation_attributes(attributes, current_period)
|
||||||
|
|
||||||
|
|
@ -429,6 +478,7 @@ def build_final_attributes_simple(
|
||||||
result: dict = {
|
result: dict = {
|
||||||
"timestamp": timestamp,
|
"timestamp": timestamp,
|
||||||
}
|
}
|
||||||
|
add_period_count_attributes(result, period_summaries, time)
|
||||||
if period_metadata:
|
if period_metadata:
|
||||||
add_calculation_summary_attributes(result, period_metadata)
|
add_calculation_summary_attributes(result, period_metadata)
|
||||||
result["periods"] = _convert_periods_to_display_units(period_summaries, factor)
|
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 (
|
from custom_components.tibber_prices.const import (
|
||||||
BEST_PRICE_MAX_LEVEL_OPTIONS,
|
BEST_PRICE_MAX_LEVEL_OPTIONS,
|
||||||
CONF_AVERAGE_SENSOR_DISPLAY,
|
CONF_AVERAGE_SENSOR_DISPLAY,
|
||||||
|
CONF_BEST_PRICE_EXTEND_TO_VERY_CHEAP,
|
||||||
CONF_BEST_PRICE_FLEX,
|
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,
|
||||||
CONF_BEST_PRICE_MAX_LEVEL_GAP_COUNT,
|
CONF_BEST_PRICE_MAX_LEVEL_GAP_COUNT,
|
||||||
CONF_BEST_PRICE_MIN_DISTANCE_FROM_AVG,
|
CONF_BEST_PRICE_MIN_DISTANCE_FROM_AVG,
|
||||||
|
|
@ -23,7 +26,10 @@ from custom_components.tibber_prices.const import (
|
||||||
CONF_EXTENDED_DESCRIPTIONS,
|
CONF_EXTENDED_DESCRIPTIONS,
|
||||||
CONF_MIN_PERIODS_BEST,
|
CONF_MIN_PERIODS_BEST,
|
||||||
CONF_MIN_PERIODS_PEAK,
|
CONF_MIN_PERIODS_PEAK,
|
||||||
|
CONF_PEAK_PRICE_EXTEND_TO_VERY_EXPENSIVE,
|
||||||
CONF_PEAK_PRICE_FLEX,
|
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_MAX_LEVEL_GAP_COUNT,
|
||||||
CONF_PEAK_PRICE_MIN_DISTANCE_FROM_AVG,
|
CONF_PEAK_PRICE_MIN_DISTANCE_FROM_AVG,
|
||||||
CONF_PEAK_PRICE_MIN_LEVEL,
|
CONF_PEAK_PRICE_MIN_LEVEL,
|
||||||
|
|
@ -49,7 +55,10 @@ from custom_components.tibber_prices.const import (
|
||||||
CONF_VOLATILITY_THRESHOLD_MODERATE,
|
CONF_VOLATILITY_THRESHOLD_MODERATE,
|
||||||
CONF_VOLATILITY_THRESHOLD_VERY_HIGH,
|
CONF_VOLATILITY_THRESHOLD_VERY_HIGH,
|
||||||
DEFAULT_AVERAGE_SENSOR_DISPLAY,
|
DEFAULT_AVERAGE_SENSOR_DISPLAY,
|
||||||
|
DEFAULT_BEST_PRICE_EXTEND_TO_VERY_CHEAP,
|
||||||
DEFAULT_BEST_PRICE_FLEX,
|
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,
|
||||||
DEFAULT_BEST_PRICE_MAX_LEVEL_GAP_COUNT,
|
DEFAULT_BEST_PRICE_MAX_LEVEL_GAP_COUNT,
|
||||||
DEFAULT_BEST_PRICE_MIN_DISTANCE_FROM_AVG,
|
DEFAULT_BEST_PRICE_MIN_DISTANCE_FROM_AVG,
|
||||||
|
|
@ -59,7 +68,10 @@ from custom_components.tibber_prices.const import (
|
||||||
DEFAULT_EXTENDED_DESCRIPTIONS,
|
DEFAULT_EXTENDED_DESCRIPTIONS,
|
||||||
DEFAULT_MIN_PERIODS_BEST,
|
DEFAULT_MIN_PERIODS_BEST,
|
||||||
DEFAULT_MIN_PERIODS_PEAK,
|
DEFAULT_MIN_PERIODS_PEAK,
|
||||||
|
DEFAULT_PEAK_PRICE_EXTEND_TO_VERY_EXPENSIVE,
|
||||||
DEFAULT_PEAK_PRICE_FLEX,
|
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_MAX_LEVEL_GAP_COUNT,
|
||||||
DEFAULT_PEAK_PRICE_MIN_DISTANCE_FROM_AVG,
|
DEFAULT_PEAK_PRICE_MIN_DISTANCE_FROM_AVG,
|
||||||
DEFAULT_PEAK_PRICE_MIN_LEVEL,
|
DEFAULT_PEAK_PRICE_MIN_LEVEL,
|
||||||
|
|
@ -86,7 +98,9 @@ from custom_components.tibber_prices.const import (
|
||||||
DEFAULT_VOLATILITY_THRESHOLD_VERY_HIGH,
|
DEFAULT_VOLATILITY_THRESHOLD_VERY_HIGH,
|
||||||
DISPLAY_MODE_BASE,
|
DISPLAY_MODE_BASE,
|
||||||
DISPLAY_MODE_SUBUNIT,
|
DISPLAY_MODE_SUBUNIT,
|
||||||
|
MAX_EXTENSION_INTERVALS,
|
||||||
MAX_GAP_COUNT,
|
MAX_GAP_COUNT,
|
||||||
|
MAX_GEOMETRIC_FLEX,
|
||||||
MAX_MIN_PERIOD_LENGTH,
|
MAX_MIN_PERIOD_LENGTH,
|
||||||
MAX_MIN_PERIODS,
|
MAX_MIN_PERIODS,
|
||||||
MAX_PRICE_LEVEL_GAP_TOLERANCE,
|
MAX_PRICE_LEVEL_GAP_TOLERANCE,
|
||||||
|
|
@ -618,6 +632,7 @@ def get_best_price_schema(
|
||||||
period_settings = options.get("period_settings", {})
|
period_settings = options.get("period_settings", {})
|
||||||
flexibility_settings = options.get("flexibility_settings", {})
|
flexibility_settings = options.get("flexibility_settings", {})
|
||||||
relaxation_settings = options.get("relaxation_and_target_periods", {})
|
relaxation_settings = options.get("relaxation_and_target_periods", {})
|
||||||
|
extension_settings = options.get("extension_settings", {})
|
||||||
|
|
||||||
# Get current values for override display
|
# Get current values for override display
|
||||||
min_period_length = int(
|
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)
|
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))
|
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))
|
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
|
# Build section schemas with optional override warnings
|
||||||
period_warning = get_section_override_warning("best_price", "period_settings", overrides, translations) or {}
|
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),
|
vol.Schema(relaxation_fields),
|
||||||
{"collapsed": True},
|
{"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", {})
|
period_settings = options.get("period_settings", {})
|
||||||
flexibility_settings = options.get("flexibility_settings", {})
|
flexibility_settings = options.get("flexibility_settings", {})
|
||||||
relaxation_settings = options.get("relaxation_and_target_periods", {})
|
relaxation_settings = options.get("relaxation_and_target_periods", {})
|
||||||
|
extension_settings = options.get("extension_settings", {})
|
||||||
|
|
||||||
# Get current values for override display
|
# Get current values for override display
|
||||||
min_period_length = int(
|
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)
|
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))
|
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))
|
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
|
# Build section schemas with optional override warnings
|
||||||
period_warning = get_section_override_warning("peak_price", "period_settings", overrides, translations) or {}
|
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),
|
vol.Schema(relaxation_fields),
|
||||||
{"collapsed": True},
|
{"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_ENABLE_MIN_PERIODS_PEAK = "enable_min_periods_peak"
|
||||||
CONF_MIN_PERIODS_PEAK = "min_periods_peak"
|
CONF_MIN_PERIODS_PEAK = "min_periods_peak"
|
||||||
CONF_RELAXATION_ATTEMPTS_PEAK = "relaxation_attempts_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"
|
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_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_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_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)
|
# Validation limits (used in GUI schemas and server-side validation)
|
||||||
# These ensure consistency between frontend and backend 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_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_MIN_PERIODS = 10 # Maximum number of minimum periods per day (GUI slider limit)
|
||||||
MAX_RELAXATION_ATTEMPTS = 12 # Maximum relaxation attempts (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)
|
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)
|
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_MIN_PERIODS_PEAK: DEFAULT_MIN_PERIODS_PEAK,
|
||||||
CONF_RELAXATION_ATTEMPTS_PEAK: DEFAULT_RELAXATION_ATTEMPTS_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:
|
else:
|
||||||
# Check for repair conditions after successful update
|
# Check for repair conditions after successful update
|
||||||
await self._check_repair_conditions(result, current_time)
|
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
|
return result
|
||||||
|
|
||||||
async def _track_rate_limit_error(self, error: Exception) -> None:
|
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."""
|
"""Get threshold percentages from config options."""
|
||||||
return self._data_transformer.get_threshold_percentages()
|
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."""
|
"""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]:
|
def _transform_data(self, raw_data: dict[str, Any]) -> dict[str, Any]:
|
||||||
"""Transform raw data for main entry (aggregated view of all homes)."""
|
"""Transform raw data for main entry (aggregated view of all homes)."""
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import logging
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
from custom_components.tibber_prices import const as _const
|
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
|
from custom_components.tibber_prices.utils.price import enrich_price_info_with_differences
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
|
|
@ -27,7 +28,7 @@ class TibberPricesDataTransformer:
|
||||||
self,
|
self,
|
||||||
config_entry: ConfigEntry,
|
config_entry: ConfigEntry,
|
||||||
log_prefix: str,
|
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,
|
time: TibberPricesTimeService,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Initialize the data transformer."""
|
"""Initialize the data transformer."""
|
||||||
|
|
@ -270,9 +271,18 @@ class TibberPricesDataTransformer:
|
||||||
"currency": currency,
|
"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)
|
# Calculate periods (best price and peak price)
|
||||||
if "priceInfo" in transformed_data:
|
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
|
# Cache the transformed data
|
||||||
self._cached_transformed_data = transformed_data
|
self._cached_transformed_data = transformed_data
|
||||||
|
|
|
||||||
|
|
@ -19,20 +19,37 @@ from __future__ import annotations
|
||||||
# Re-export main API functions
|
# Re-export main API functions
|
||||||
from .core import calculate_periods
|
from .core import calculate_periods
|
||||||
|
|
||||||
|
# Re-export day pattern detection
|
||||||
|
from .day_pattern import detect_day_patterns
|
||||||
|
|
||||||
# Re-export outlier filtering
|
# Re-export outlier filtering
|
||||||
from .outlier_filtering import filter_price_outliers
|
from .outlier_filtering import filter_price_outliers
|
||||||
|
|
||||||
# Re-export relaxation
|
# Re-export relaxation
|
||||||
from .relaxation import calculate_periods_with_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
|
# Re-export constants and types
|
||||||
from .types import (
|
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_L0,
|
||||||
INDENT_L1,
|
INDENT_L1,
|
||||||
INDENT_L2,
|
INDENT_L2,
|
||||||
INDENT_L3,
|
INDENT_L3,
|
||||||
INDENT_L4,
|
INDENT_L4,
|
||||||
INDENT_L5,
|
INDENT_L5,
|
||||||
|
DayPatternDict,
|
||||||
|
SegmentDict,
|
||||||
TibberPricesIntervalCriteria,
|
TibberPricesIntervalCriteria,
|
||||||
TibberPricesPeriodConfig,
|
TibberPricesPeriodConfig,
|
||||||
TibberPricesPeriodData,
|
TibberPricesPeriodData,
|
||||||
|
|
@ -41,12 +58,23 @@ from .types import (
|
||||||
)
|
)
|
||||||
|
|
||||||
__all__ = [
|
__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_L0",
|
||||||
"INDENT_L1",
|
"INDENT_L1",
|
||||||
"INDENT_L2",
|
"INDENT_L2",
|
||||||
"INDENT_L3",
|
"INDENT_L3",
|
||||||
"INDENT_L4",
|
"INDENT_L4",
|
||||||
"INDENT_L5",
|
"INDENT_L5",
|
||||||
|
"DayPatternDict",
|
||||||
|
"SegmentDict",
|
||||||
"TibberPricesIntervalCriteria",
|
"TibberPricesIntervalCriteria",
|
||||||
"TibberPricesPeriodConfig",
|
"TibberPricesPeriodConfig",
|
||||||
"TibberPricesPeriodData",
|
"TibberPricesPeriodData",
|
||||||
|
|
@ -54,5 +82,7 @@ __all__ = [
|
||||||
"TibberPricesThresholdConfig",
|
"TibberPricesThresholdConfig",
|
||||||
"calculate_periods",
|
"calculate_periods",
|
||||||
"calculate_periods_with_relaxation",
|
"calculate_periods_with_relaxation",
|
||||||
|
"detect_day_patterns",
|
||||||
|
"extend_periods_for_shape",
|
||||||
"filter_price_outliers",
|
"filter_price_outliers",
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ from .period_building import (
|
||||||
from .period_statistics import (
|
from .period_statistics import (
|
||||||
extract_period_summaries,
|
extract_period_summaries,
|
||||||
)
|
)
|
||||||
|
from .shape_extension import extend_periods_for_shape
|
||||||
from .types import TibberPricesThresholdConfig
|
from .types import TibberPricesThresholdConfig
|
||||||
|
|
||||||
# Flex limits to prevent degenerate behavior (see docs/development/period-calculation-theory.md)
|
# Flex limits to prevent degenerate behavior (see docs/development/period-calculation-theory.md)
|
||||||
|
|
@ -37,6 +38,7 @@ def calculate_periods(
|
||||||
*,
|
*,
|
||||||
config: TibberPricesPeriodConfig,
|
config: TibberPricesPeriodConfig,
|
||||||
time: TibberPricesTimeService,
|
time: TibberPricesTimeService,
|
||||||
|
day_patterns_by_date: dict | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Calculate price periods (best or peak) from price data.
|
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,
|
config: Period configuration containing reverse_sort, flex, min_distance_from_avg,
|
||||||
min_period_length, threshold_low, and threshold_high.
|
min_period_length, threshold_low, and threshold_high.
|
||||||
time: TibberPricesTimeService instance (required).
|
time: TibberPricesTimeService instance (required).
|
||||||
|
day_patterns_by_date: Optional dict mapping date → day pattern dict for geometric flex bonus.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dict with:
|
Dict with:
|
||||||
|
|
@ -155,6 +158,8 @@ def calculate_periods(
|
||||||
"intervals_by_day": intervals_by_day, # Needed for day volatility calculation
|
"intervals_by_day": intervals_by_day, # Needed for day volatility calculation
|
||||||
"flex": flex,
|
"flex": flex,
|
||||||
"min_distance_from_avg": min_distance_from_avg,
|
"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(
|
raw_periods = build_periods(
|
||||||
all_prices_smoothed, # Use smoothed prices for period formation
|
all_prices_smoothed, # Use smoothed prices for period formation
|
||||||
|
|
@ -209,6 +214,20 @@ def calculate_periods(
|
||||||
time=time,
|
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
|
# Step 8: Cross-day extension for late-night periods
|
||||||
# If a best-price period ends near midnight and tomorrow has continued low prices,
|
# 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
|
# 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 __future__ import annotations
|
||||||
|
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
from .types import TibberPricesIntervalCriteria
|
from .types import TibberPricesIntervalCriteria
|
||||||
|
|
||||||
from custom_components.tibber_prices.const import PRICE_LEVEL_MAPPING
|
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
|
meets_min_distance = price <= min_distance_threshold
|
||||||
|
|
||||||
return in_flex, meets_min_distance
|
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 (
|
from .level_filtering import (
|
||||||
apply_level_filter,
|
apply_level_filter,
|
||||||
check_interval_criteria,
|
check_interval_criteria,
|
||||||
|
compute_geometric_flex_bonus,
|
||||||
)
|
)
|
||||||
from .types import TibberPricesIntervalCriteria
|
from .types import TibberPricesIntervalCriteria
|
||||||
|
|
||||||
|
|
@ -83,6 +84,8 @@ def build_periods( # noqa: PLR0913, PLR0915, PLR0912 - Complex period building
|
||||||
avg_prices = price_context["avg_prices"]
|
avg_prices = price_context["avg_prices"]
|
||||||
flex = price_context["flex"]
|
flex = price_context["flex"]
|
||||||
min_distance_from_avg = price_context["min_distance_from_avg"]
|
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
|
# Calculate level_order if level_filter is active
|
||||||
level_order = None
|
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)
|
# Check flex and minimum distance criteria (using smoothed price and interval's own day reference)
|
||||||
criteria = criteria_by_day[ref_date]
|
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
|
# Track why intervals are filtered
|
||||||
if not in_flex:
|
if not in_flex:
|
||||||
|
|
@ -159,7 +175,7 @@ def build_periods( # noqa: PLR0913, PLR0915, PLR0912 - Complex period building
|
||||||
smoothing_was_impactful = False
|
smoothing_was_impactful = False
|
||||||
if price_data.get("_smoothed", False):
|
if price_data.get("_smoothed", False):
|
||||||
# Check if original price would have passed the same criteria
|
# 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 if original would have failed but smoothed passed
|
||||||
smoothing_was_impactful = (in_flex and meets_min_distance) and not (
|
smoothing_was_impactful = (in_flex and meets_min_distance) and not (
|
||||||
in_flex_original and meets_min_distance_original
|
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
|
# Only True if smoothing changed whether the interval qualified for period inclusion
|
||||||
"smoothing_was_impactful": smoothing_was_impactful,
|
"smoothing_was_impactful": smoothing_was_impactful,
|
||||||
"is_level_gap": is_level_gap, # Track if kept due to level gap tolerance
|
"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:
|
elif current_period:
|
||||||
|
|
|
||||||
|
|
@ -220,6 +220,17 @@ def build_period_summary_dict(
|
||||||
return summary
|
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(
|
def extract_period_summaries(
|
||||||
periods: list[list[dict]],
|
periods: list[list[dict]],
|
||||||
all_prices: list[dict],
|
all_prices: list[dict],
|
||||||
|
|
@ -328,12 +339,6 @@ def extract_period_summaries(
|
||||||
).lower()
|
).lower()
|
||||||
rating_difference_pct = calculate_aggregated_rating_difference(period_price_data)
|
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
|
# Build period data and statistics objects
|
||||||
period_data = TibberPricesPeriodData(
|
period_data = TibberPricesPeriodData(
|
||||||
start_time=start_time,
|
start_time=start_time,
|
||||||
|
|
@ -363,13 +368,8 @@ def extract_period_summaries(
|
||||||
period_data, stats, reverse_sort=thresholds.reverse_sort, price_context=price_context
|
period_data, stats, reverse_sort=thresholds.reverse_sort, price_context=price_context
|
||||||
)
|
)
|
||||||
|
|
||||||
# Add smoothing information if any intervals benefited from smoothing
|
# Add optional interval flag counts (smoothing, level gaps, geometric extension)
|
||||||
if smoothed_impactful_count > 0:
|
_add_interval_flag_counts(summary, period)
|
||||||
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
|
|
||||||
|
|
||||||
summaries.append(summary)
|
summaries.append(summary)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -526,6 +526,7 @@ def calculate_periods_with_relaxation( # noqa: PLR0912, PLR0913, PLR0915 - Per-
|
||||||
should_show_callback: Callable[[str | None], bool],
|
should_show_callback: Callable[[str | None], bool],
|
||||||
time: TibberPricesTimeService,
|
time: TibberPricesTimeService,
|
||||||
config_entry: Any, # ConfigEntry type
|
config_entry: Any, # ConfigEntry type
|
||||||
|
day_patterns_by_date: dict | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Calculate periods with optional per-day filter relaxation.
|
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.
|
to use original configured filter values.
|
||||||
time: TibberPricesTimeService instance (required).
|
time: TibberPricesTimeService instance (required).
|
||||||
config_entry: Config entry to get display unit configuration.
|
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:
|
Returns:
|
||||||
Dict with same format as calculate_periods() output:
|
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) ===
|
# === BASELINE CALCULATION (process ALL prices together, including yesterday) ===
|
||||||
# Periods that ended before yesterday will be filtered out later by filter_periods_by_end_date()
|
# 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
|
# 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"]
|
all_periods = baseline_result["periods"]
|
||||||
|
|
||||||
# Count periods per day for min_periods check
|
# 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,
|
baseline_periods=all_periods,
|
||||||
time=time,
|
time=time,
|
||||||
config_entry=config_entry,
|
config_entry=config_entry,
|
||||||
|
day_patterns_by_date=day_patterns_by_date,
|
||||||
)
|
)
|
||||||
|
|
||||||
all_periods = relaxed_result["periods"]
|
all_periods = relaxed_result["periods"]
|
||||||
|
|
@ -865,6 +869,7 @@ def relax_all_prices( # noqa: PLR0913 - Comprehensive filter relaxation require
|
||||||
*,
|
*,
|
||||||
time: TibberPricesTimeService,
|
time: TibberPricesTimeService,
|
||||||
config_entry: Any, # ConfigEntry type
|
config_entry: Any, # ConfigEntry type
|
||||||
|
day_patterns_by_date: dict | None = None,
|
||||||
) -> tuple[dict[str, Any], dict[str, Any]]:
|
) -> tuple[dict[str, Any], dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
Relax filters for all prices until min_periods per day is reached.
|
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).
|
baseline_periods: Baseline periods (before relaxation).
|
||||||
time: TibberPricesTimeService instance.
|
time: TibberPricesTimeService instance.
|
||||||
config_entry: Config entry to get display unit configuration.
|
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:
|
Returns:
|
||||||
Tuple of (result_dict, metadata_dict)
|
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)
|
# 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"]
|
new_periods = result["periods"]
|
||||||
|
|
||||||
_LOGGER_DETAILS.debug(
|
_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 __future__ import annotations
|
||||||
|
|
||||||
from typing import TYPE_CHECKING, NamedTuple
|
from typing import TYPE_CHECKING, NamedTuple, TypedDict
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
@ -56,6 +56,9 @@ class TibberPricesPeriodConfig(NamedTuple):
|
||||||
threshold_volatility_very_high: float = DEFAULT_VOLATILITY_THRESHOLD_VERY_HIGH
|
threshold_volatility_very_high: float = DEFAULT_VOLATILITY_THRESHOLD_VERY_HIGH
|
||||||
level_filter: str | None = None # "any", "cheap", "expensive", etc. or None
|
level_filter: str | None = None # "any", "cheap", "expensive", etc. or None
|
||||||
gap_count: int = 0 # Number of allowed consecutive deviating intervals
|
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):
|
class TibberPricesPeriodData(NamedTuple):
|
||||||
|
|
@ -104,3 +107,60 @@ class TibberPricesIntervalCriteria(NamedTuple):
|
||||||
flex: float
|
flex: float
|
||||||
min_distance_from_avg: float
|
min_distance_from_avg: float
|
||||||
reverse_sort: bool
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
from datetime import date, timedelta
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
from custom_components.tibber_prices import const as _const
|
from custom_components.tibber_prices import const as _const
|
||||||
|
|
@ -225,6 +226,60 @@ class TibberPricesPeriodCalculator:
|
||||||
"min_period_length": int(min_period_length),
|
"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
|
# Cache the result
|
||||||
self._config_cache[cache_key] = config
|
self._config_cache[cache_key] = config
|
||||||
self._config_cache_valid = True
|
self._config_cache_valid = True
|
||||||
|
|
@ -598,6 +653,7 @@ class TibberPricesPeriodCalculator:
|
||||||
def calculate_periods_for_price_info(
|
def calculate_periods_for_price_info(
|
||||||
self,
|
self,
|
||||||
price_info: dict[str, Any],
|
price_info: dict[str, Any],
|
||||||
|
day_patterns: dict[str, Any] | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Calculate periods (best price and peak price) for the given price info.
|
Calculate periods (best price and peak price) for the given price info.
|
||||||
|
|
@ -622,6 +678,19 @@ class TibberPricesPeriodCalculator:
|
||||||
coordinator_data = {"priceInfo": price_info}
|
coordinator_data = {"priceInfo": price_info}
|
||||||
all_prices = get_intervals_for_day_offsets(coordinator_data, [-2, -1, 0, 1])
|
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)
|
# Get rating thresholds from config (flat in options, not in sections)
|
||||||
# CRITICAL: Price rating thresholds are stored FLAT in options (no sections)
|
# CRITICAL: Price rating thresholds are stored FLAT in options (no sections)
|
||||||
threshold_low = self.config_entry.options.get(
|
threshold_low = self.config_entry.options.get(
|
||||||
|
|
@ -702,6 +771,9 @@ class TibberPricesPeriodCalculator:
|
||||||
threshold_volatility_very_high=threshold_volatility_very_high,
|
threshold_volatility_very_high=threshold_volatility_very_high,
|
||||||
level_filter=max_level_best,
|
level_filter=max_level_best,
|
||||||
gap_count=gap_count_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(
|
best_periods = calculate_periods_with_relaxation(
|
||||||
all_prices,
|
all_prices,
|
||||||
|
|
@ -716,6 +788,7 @@ class TibberPricesPeriodCalculator:
|
||||||
),
|
),
|
||||||
time=self.time,
|
time=self.time,
|
||||||
config_entry=self.config_entry,
|
config_entry=self.config_entry,
|
||||||
|
day_patterns_by_date=day_patterns_by_date,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
best_periods = {
|
best_periods = {
|
||||||
|
|
@ -783,6 +856,9 @@ class TibberPricesPeriodCalculator:
|
||||||
threshold_volatility_very_high=threshold_volatility_very_high,
|
threshold_volatility_very_high=threshold_volatility_very_high,
|
||||||
level_filter=min_level_peak,
|
level_filter=min_level_peak,
|
||||||
gap_count=gap_count_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(
|
peak_periods = calculate_periods_with_relaxation(
|
||||||
all_prices,
|
all_prices,
|
||||||
|
|
@ -797,6 +873,7 @@ class TibberPricesPeriodCalculator:
|
||||||
),
|
),
|
||||||
time=self.time,
|
time=self.time,
|
||||||
config_entry=self.config_entry,
|
config_entry=self.config_entry,
|
||||||
|
day_patterns_by_date=day_patterns_by_date,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
peak_periods = {
|
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.",
|
"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."
|
"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": {
|
"chart_data_export": {
|
||||||
"description": "Datenexport für Dashboard-Integrationen",
|
"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.",
|
"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.",
|
"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."
|
"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": {
|
"chart_data_export": {
|
||||||
"description": "Data export for dashboard integrations",
|
"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.",
|
"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.",
|
"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."
|
"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": {
|
"chart_data_export": {
|
||||||
"description": "Dataeksport for dashboardintegrasjoner",
|
"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.",
|
"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.",
|
"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."
|
"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": {
|
"chart_data_export": {
|
||||||
"description": "Data-export voor dashboard-integraties",
|
"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.",
|
"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.",
|
"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."
|
"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": {
|
"chart_data_export": {
|
||||||
"description": "Dataexport för dashboard-integrationer",
|
"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.",
|
"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": {
|
"refresh_user_data": {
|
||||||
"service": "mdi:refresh"
|
"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 .future import add_next_avg_attributes, get_future_prices
|
||||||
from .interval import add_current_interval_price_attributes
|
from .interval import add_current_interval_price_attributes
|
||||||
from .lifecycle import build_lifecycle_attributes
|
from .lifecycle import build_lifecycle_attributes
|
||||||
|
from .metadata import get_day_pattern_attributes
|
||||||
from .timing import _is_timing_or_volatility_sensor
|
from .timing import _is_timing_or_volatility_sensor
|
||||||
from .trend import _add_cached_trend_attributes, _add_timing_or_volatility_attributes
|
from .trend import _add_cached_trend_attributes, _add_timing_or_volatility_attributes
|
||||||
from .volatility import add_volatility_type_attributes, get_prices_for_volatility
|
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,
|
key: str,
|
||||||
coordinator: TibberPricesDataUpdateCoordinator,
|
coordinator: TibberPricesDataUpdateCoordinator,
|
||||||
native_value: Any,
|
native_value: Any,
|
||||||
|
|
@ -189,6 +190,12 @@ def build_sensor_attributes(
|
||||||
elif _is_timing_or_volatility_sensor(key):
|
elif _is_timing_or_volatility_sensor(key):
|
||||||
_add_timing_or_volatility_attributes(attributes, key, cached_data, native_value, time=time)
|
_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
|
# 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:
|
if key == "current_interval_price_level" and cached_data.get("last_price_level") is not None:
|
||||||
attributes["level_id"] = cached_data["last_price_level"]
|
attributes["level_id"] = cached_data["last_price_level"]
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
from __future__ import annotations
|
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
|
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()
|
now = time.now()
|
||||||
|
|
||||||
return find_price_data_for_interval(coordinator.data, now, time=time)
|
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.lower()
|
||||||
|
|
||||||
return value
|
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 = (
|
DIAGNOSTIC_SENSORS = (
|
||||||
|
|
@ -1055,5 +1118,6 @@ ENTITY_DESCRIPTIONS = (
|
||||||
*VOLATILITY_SENSORS,
|
*VOLATILITY_SENSORS,
|
||||||
*BEST_PRICE_TIMING_SENSORS,
|
*BEST_PRICE_TIMING_SENSORS,
|
||||||
*PEAK_PRICE_TIMING_SENSORS,
|
*PEAK_PRICE_TIMING_SENSORS,
|
||||||
|
*DAY_PATTERN_SENSORS,
|
||||||
*DIAGNOSTIC_SENSORS,
|
*DIAGNOSTIC_SENSORS,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -238,6 +238,10 @@ def get_value_getter_mapping( # noqa: PLR0913 - needs all calculators as parame
|
||||||
),
|
),
|
||||||
# Subscription sensors (via MetadataCalculator)
|
# Subscription sensors (via MetadataCalculator)
|
||||||
"subscription_status": lambda: metadata_calculator.get_subscription_value("status"),
|
"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)
|
# Volatility sensors (via VolatilityCalculator)
|
||||||
"today_volatility": lambda: volatility_calculator.get_volatility_value(volatility_type="today"),
|
"today_volatility": lambda: volatility_calculator.get_volatility_value(volatility_type="today"),
|
||||||
"tomorrow_volatility": lambda: volatility_calculator.get_volatility_value(volatility_type="tomorrow"),
|
"tomorrow_volatility": lambda: volatility_calculator.get_volatility_value(volatility_type="tomorrow"),
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
get_price:
|
get_price:
|
||||||
fields:
|
fields:
|
||||||
entry_id:
|
entry_id:
|
||||||
required: true
|
required: false
|
||||||
example: "1234567890abcdef"
|
example: "1234567890abcdef"
|
||||||
selector:
|
selector:
|
||||||
config_entry:
|
config_entry:
|
||||||
|
|
@ -19,7 +19,7 @@ get_price:
|
||||||
get_apexcharts_yaml:
|
get_apexcharts_yaml:
|
||||||
fields:
|
fields:
|
||||||
entry_id:
|
entry_id:
|
||||||
required: true
|
required: false
|
||||||
example: "1234567890abcdef"
|
example: "1234567890abcdef"
|
||||||
selector:
|
selector:
|
||||||
config_entry:
|
config_entry:
|
||||||
|
|
@ -73,7 +73,7 @@ get_chartdata:
|
||||||
general:
|
general:
|
||||||
fields:
|
fields:
|
||||||
entry_id:
|
entry_id:
|
||||||
required: true
|
required: false
|
||||||
example: "1234567890abcdef"
|
example: "1234567890abcdef"
|
||||||
selector:
|
selector:
|
||||||
config_entry:
|
config_entry:
|
||||||
|
|
@ -274,12 +274,772 @@ get_chartdata:
|
||||||
refresh_user_data:
|
refresh_user_data:
|
||||||
fields:
|
fields:
|
||||||
entry_id:
|
entry_id:
|
||||||
required: true
|
required: false
|
||||||
example: "1234567890abcdef"
|
example: "1234567890abcdef"
|
||||||
selector:
|
selector:
|
||||||
config_entry:
|
config_entry:
|
||||||
integration: tibber_prices
|
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:
|
debug_clear_tomorrow:
|
||||||
fields:
|
fields:
|
||||||
entry_id:
|
entry_id:
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,31 @@ from typing import TYPE_CHECKING
|
||||||
from custom_components.tibber_prices.const import DOMAIN
|
from custom_components.tibber_prices.const import DOMAIN
|
||||||
from homeassistant.core import SupportsResponse, callback
|
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 (
|
from .get_apexcharts_yaml import (
|
||||||
APEXCHARTS_SERVICE_SCHEMA,
|
APEXCHARTS_SERVICE_SCHEMA,
|
||||||
APEXCHARTS_YAML_SERVICE_NAME,
|
APEXCHARTS_YAML_SERVICE_NAME,
|
||||||
|
|
@ -73,6 +98,41 @@ def async_setup_services(hass: HomeAssistant) -> None:
|
||||||
schema=GET_PRICE_SERVICE_SCHEMA,
|
schema=GET_PRICE_SERVICE_SCHEMA,
|
||||||
supports_response=SupportsResponse.ONLY,
|
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(
|
hass.services.async_register(
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
REFRESH_USER_DATA_SERVICE_NAME,
|
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 (
|
from custom_components.tibber_prices.const import (
|
||||||
CONF_CURRENCY_DISPLAY_MODE,
|
CONF_CURRENCY_DISPLAY_MODE,
|
||||||
DISPLAY_MODE_SUBUNIT,
|
DISPLAY_MODE_SUBUNIT,
|
||||||
DOMAIN,
|
|
||||||
PRICE_LEVEL_CHEAP,
|
PRICE_LEVEL_CHEAP,
|
||||||
PRICE_LEVEL_EXPENSIVE,
|
PRICE_LEVEL_EXPENSIVE,
|
||||||
PRICE_LEVEL_NORMAL,
|
PRICE_LEVEL_NORMAL,
|
||||||
|
|
@ -37,7 +36,6 @@ from custom_components.tibber_prices.const import (
|
||||||
get_display_unit_string,
|
get_display_unit_string,
|
||||||
get_translation,
|
get_translation,
|
||||||
)
|
)
|
||||||
from homeassistant.exceptions import ServiceValidationError
|
|
||||||
from homeassistant.helpers import config_validation as cv
|
from homeassistant.helpers import config_validation as cv
|
||||||
from homeassistant.helpers.entity_registry import (
|
from homeassistant.helpers.entity_registry import (
|
||||||
EntityRegistry,
|
EntityRegistry,
|
||||||
|
|
@ -60,7 +58,7 @@ ATTR_ENTRY_ID: Final = "entry_id"
|
||||||
# Service schema
|
# Service schema
|
||||||
APEXCHARTS_SERVICE_SCHEMA = vol.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("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("level_type", default="rating_level"): vol.In(["rating_level", "level"]),
|
||||||
vol.Optional("resolution", default="interval"): vol.In(["interval", "hourly"]),
|
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
|
hass = call.hass
|
||||||
entry_id_raw = call.data.get(ATTR_ENTRY_ID)
|
entry_id_input: str = 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)
|
|
||||||
|
|
||||||
day = call.data.get("day") # Can be None (rolling window mode)
|
day = call.data.get("day") # Can be None (rolling window mode)
|
||||||
level_type = call.data.get("level_type", "rating_level")
|
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"
|
user_language = hass.config.language or "en"
|
||||||
|
|
||||||
# Get coordinator to access price data (for currency) and config entry for display settings
|
# 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
|
# Get currency from coordinator data
|
||||||
currency = coordinator.data.get("currency", "EUR")
|
currency = coordinator.data.get("currency", "EUR")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -270,7 +270,7 @@ ATTR_ENTRY_ID: Final = "entry_id"
|
||||||
# Service schema
|
# Service schema
|
||||||
CHARTDATA_SERVICE_SCHEMA: Final = vol.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(ATTR_DAY): vol.All(vol.Coerce(list), [vol.In(["yesterday", "today", "tomorrow"])]),
|
||||||
vol.Optional("resolution", default="interval"): vol.In(["interval", "hourly"]),
|
vol.Optional("resolution", default="interval"): vol.In(["interval", "hourly"]),
|
||||||
vol.Optional("output_format", default="array_of_objects"): vol.In(["array_of_objects", "array_of_arrays"]),
|
vol.Optional("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
|
hass = call.hass
|
||||||
entry_id_raw = call.data.get(ATTR_ENTRY_ID)
|
entry_id: str = 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)
|
|
||||||
|
|
||||||
# Get coordinator to check data availability
|
# Get coordinator to check data availability
|
||||||
_, coordinator, _ = get_entry_and_data(hass, entry_id)
|
_, 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")
|
level_filter = call.data.get("level_filter")
|
||||||
rating_level_filter = call.data.get("rating_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 ===
|
# === METADATA-ONLY MODE ===
|
||||||
# Early return: calculate and return only metadata, skip all data processing
|
# Early return: calculate and return only metadata, skip all data processing
|
||||||
if metadata == "only":
|
if metadata == "only":
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,7 @@ GET_PRICE_SERVICE_NAME = "get_price"
|
||||||
|
|
||||||
GET_PRICE_SERVICE_SCHEMA = vol.Schema(
|
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("start_time"): cv.datetime,
|
||||||
vol.Required("end_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
|
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"]
|
start_time: datetime = call.data["start_time"]
|
||||||
end_time: datetime = call.data["end_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
|
# Step 2: Convert to home timezone
|
||||||
end_time = end_time.astimezone(home_tz)
|
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(
|
_LOGGER.info(
|
||||||
"get_price service called: entry_id=%s, home_id=%s, range=%s to %s",
|
"get_price service called: entry_id=%s, home_id=%s, range=%s to %s",
|
||||||
entry_id,
|
entry_id,
|
||||||
|
|
|
||||||
|
|
@ -2,52 +2,198 @@
|
||||||
Shared utilities for service handlers.
|
Shared utilities for service handlers.
|
||||||
|
|
||||||
This module provides common helper functions used across multiple 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:
|
Functions:
|
||||||
get_entry_and_data: Validate config entry and extract coordinator data
|
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:
|
Used by:
|
||||||
- services/chartdata.py: Chart data export service
|
- services/chartdata.py: Chart data export service
|
||||||
- services/apexcharts.py: ApexCharts YAML generation
|
- services/apexcharts.py: ApexCharts YAML generation
|
||||||
- services/refresh_user_data.py: User data refresh
|
- 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 __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from datetime import time as dt_time
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
from custom_components.tibber_prices.const import DOMAIN
|
from custom_components.tibber_prices.const import DOMAIN
|
||||||
from custom_components.tibber_prices.coordinator.helpers import get_intervals_for_day_offsets
|
from custom_components.tibber_prices.coordinator.helpers import get_intervals_for_day_offsets
|
||||||
from homeassistant.exceptions import ServiceValidationError
|
from homeassistant.exceptions import ServiceValidationError
|
||||||
|
from homeassistant.util import dt as dt_utils
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
from custom_components.tibber_prices.coordinator import TibberPricesDataUpdateCoordinator
|
from custom_components.tibber_prices.coordinator import TibberPricesDataUpdateCoordinator
|
||||||
from homeassistant.core import HomeAssistant
|
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]:
|
def get_entry_and_data(hass: HomeAssistant, entry_id: str) -> tuple[Any, Any, dict]:
|
||||||
"""
|
"""
|
||||||
Validate entry and extract coordinator and data.
|
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:
|
Args:
|
||||||
hass: Home Assistant instance
|
hass: Home Assistant instance
|
||||||
entry_id: Config entry ID to validate
|
entry_id: Config entry ID to validate (empty string to auto-select)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Tuple of (entry, coordinator, data)
|
Tuple of (entry, coordinator, data)
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
ServiceValidationError: If entry_id is missing or invalid
|
ServiceValidationError: If entry cannot be resolved
|
||||||
|
|
||||||
"""
|
"""
|
||||||
if not entry_id:
|
if not entry_id:
|
||||||
raise ServiceValidationError(translation_domain=DOMAIN, translation_key="missing_entry_id")
|
entries = hass.config_entries.async_entries(DOMAIN)
|
||||||
entry = next(
|
if len(entries) == 1:
|
||||||
(e for e in hass.config_entries.async_entries(DOMAIN) if e.entry_id == entry_id),
|
entry = entries[0]
|
||||||
None,
|
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,
|
||||||
|
)
|
||||||
if not entry or not hasattr(entry, "runtime_data") or not entry.runtime_data:
|
if not entry or not hasattr(entry, "runtime_data") or not entry.runtime_data:
|
||||||
raise ServiceValidationError(translation_domain=DOMAIN, translation_key="invalid_entry_id")
|
raise ServiceValidationError(translation_domain=DOMAIN, translation_key="invalid_entry_id")
|
||||||
coordinator = entry.runtime_data.coordinator
|
coordinator = entry.runtime_data.coordinator
|
||||||
|
|
@ -72,3 +218,276 @@ def has_tomorrow_data(coordinator: TibberPricesDataUpdateCoordinator) -> bool:
|
||||||
coordinator_data = coordinator.data or {}
|
coordinator_data = coordinator.data or {}
|
||||||
tomorrow_intervals = get_intervals_for_day_offsets(coordinator_data, [1])
|
tomorrow_intervals = get_intervals_for_day_offsets(coordinator_data, [1])
|
||||||
return len(tomorrow_intervals) > 0
|
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
|
# Service schema
|
||||||
REFRESH_USER_DATA_SERVICE_SCHEMA: Final = vol.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
|
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
|
hass = call.hass
|
||||||
|
|
||||||
if not entry_id:
|
|
||||||
return {
|
|
||||||
"success": False,
|
|
||||||
"message": "Entry ID is required",
|
|
||||||
}
|
|
||||||
|
|
||||||
# Get the entry and coordinator
|
# Get the entry and coordinator
|
||||||
try:
|
try:
|
||||||
_, coordinator, _ = get_entry_and_data(hass, entry_id)
|
_, coordinator, _ = get_entry_and_data(hass, entry_id)
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
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
|
### 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`.
|
The ID looks like a long alphanumeric string, for example `01JKPC7AB3EF4GH5IJ6KL7MN8P`.
|
||||||
|
|
||||||
:::tip Multiple homes?
|
:::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
|
**→ [Scheduling Actions — Full Guide](scheduling-actions.md)** with parameters, response formats, decision flowchart, and automation examples.
|
||||||
- **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:**
|
### Chart & Visualization Actions
|
||||||
|
|
||||||
```yaml
|
Generate chart-ready data and ApexCharts configurations for your dashboards.
|
||||||
service: tibber_prices.get_chartdata
|
|
||||||
data:
|
|
||||||
entry_id: YOUR_CONFIG_ENTRY_ID
|
|
||||||
day: ["today", "tomorrow"]
|
|
||||||
output_format: array_of_objects
|
|
||||||
response_variable: chart_data
|
|
||||||
```
|
|
||||||
|
|
||||||
**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
|
**→ [Chart & Visualization Actions — Full Guide](chart-actions.md)** with parameters, examples, rolling window modes, and migration guide.
|
||||||
{
|
|
||||||
"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
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Common Parameters:**
|
### Data & Utility Actions
|
||||||
|
|
||||||
| Parameter | Description | Default |
|
Fetch raw price data or refresh cached information.
|
||||||
| ---------------- | ------------------------------------------- | ----------------------- |
|
|
||||||
| `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) |
|
|
||||||
|
|
||||||
**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:
|
**→ [Data & Utility Actions — Full Guide](data-actions.md)** with parameters and response formats.
|
||||||
|
|
||||||
```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
|
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@
|
||||||
- [Price-Based Automations](#price-based-automations)
|
- [Price-Based Automations](#price-based-automations)
|
||||||
- [Volatility-Aware Automations](#volatility-aware-automations)
|
- [Volatility-Aware Automations](#volatility-aware-automations)
|
||||||
- [Best Hour Detection](#best-hour-detection)
|
- [Best Hour Detection](#best-hour-detection)
|
||||||
|
- [Scheduling Actions](#scheduling-actions)
|
||||||
- [Charts & Visualizations](#charts--visualizations)
|
- [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.
|
> 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
|
## 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.
|
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>
|
<details>
|
||||||
<summary>Show YAML: Heat pump — extended cheap period</summary>
|
<summary>Show YAML: Ride the Full Cheap Wave</summary>
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
automation:
|
automation:
|
||||||
|
|
@ -120,7 +123,7 @@ automation:
|
||||||
Use the trend to start slightly before the cheapest period — useful for appliances with warm-up time:
|
Use the trend to start slightly before the cheapest period — useful for appliances with warm-up time:
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary>Show YAML: Water heater — pre-heat before cheapest window</summary>
|
<summary>Show YAML: Pre-Emptive Start</summary>
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
automation:
|
automation:
|
||||||
|
|
@ -156,7 +159,7 @@ automation:
|
||||||
Stop or reduce consumption when prices are climbing:
|
Stop or reduce consumption when prices are climbing:
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary>Show YAML: EV charger — stop when prices rising</summary>
|
<summary>Show YAML: Protect Against Rising Prices</summary>
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
automation:
|
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
|
- If long-term says `falling` → cheaper hours ahead, no rush
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary>Show YAML: Heat pump — multi-window trend strategy</summary>
|
<summary>Show YAML: Multi-Window Trend Strategy</summary>
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
automation:
|
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.
|
**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>
|
<details>
|
||||||
<summary>Show YAML: Home battery — charge on best price (volatility-aware)</summary>
|
<summary>Show YAML: Meaningful Price Variations</summary>
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
automation:
|
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.
|
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>
|
<details>
|
||||||
<summary>Show YAML: EV charging — combined volatility + absolute price strategy</summary>
|
<summary>Show YAML: Volatility and Absolute Price Check</summary>
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
automation:
|
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.
|
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>
|
<details>
|
||||||
<summary>Show YAML: Heat pump — smart heating using period volatility attribute</summary>
|
<summary>Show YAML: Period Volatility Attribute</summary>
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
automation:
|
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):
|
Use future average sensors to determine the cheapest upcoming window for a timed appliance (e.g., dishwasher with 2-hour ECO program):
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary>Show YAML: Dishwasher — schedule for cheapest 2h window</summary>
|
<summary>Show YAML: Best Time for an Appliance</summary>
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
automation:
|
automation:
|
||||||
|
|
@ -476,7 +479,7 @@ automation:
|
||||||
Get a push notification when the best price period begins:
|
Get a push notification when the best price period begins:
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary>Show YAML: Notification — when cheap window starts</summary>
|
<summary>Show YAML: Cheap Window Notification</summary>
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
automation:
|
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
|
## Charts & Visualizations
|
||||||
|
|
||||||
> **Looking for chart configurations?** See the **[Chart Examples Guide](chart-examples.md)** for ApexCharts card configurations, rolling window modes, and more.
|
> **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!
|
> **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`)
|
:::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.
|
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
|
**Dependencies:** ApexCharts Card only
|
||||||
|
|
||||||
**Generate:**
|
**Generate:**
|
||||||
|
<details>
|
||||||
|
<summary>Show YAML: Today's Prices (Static)</summary>
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
service: tibber_prices.get_apexcharts_yaml
|
service: tibber_prices.get_apexcharts_yaml
|
||||||
data:
|
data:
|
||||||
|
|
@ -48,6 +53,8 @@ data:
|
||||||
highlight_best_price: true
|
highlight_best_price: true
|
||||||
```
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
**Screenshot:**
|
**Screenshot:**
|
||||||
|
|
||||||

|

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

|

|
||||||
|
|
@ -103,6 +115,9 @@ data:
|
||||||
**Dependencies:** ApexCharts Card + Config Template Card
|
**Dependencies:** ApexCharts Card + Config Template Card
|
||||||
|
|
||||||
**Generate:**
|
**Generate:**
|
||||||
|
<details>
|
||||||
|
<summary>Show YAML: Rolling Auto-Zoom</summary>
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
service: tibber_prices.get_apexcharts_yaml
|
service: tibber_prices.get_apexcharts_yaml
|
||||||
data:
|
data:
|
||||||
|
|
@ -112,6 +127,8 @@ data:
|
||||||
highlight_best_price: true
|
highlight_best_price: true
|
||||||
```
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
**Screenshot:**
|
**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:
|
Rolling window modes (2 & 3) automatically integrate with the `chart_metadata` sensor for optimal visualization:
|
||||||
|
|
||||||
**Without chart_metadata sensor (disabled):**
|
**Without chart_metadata sensor (disabled):**
|
||||||
|
<details>
|
||||||
|
<summary>Show chart illustration: Without Chart Metadata</summary>
|
||||||
|
|
||||||
```
|
```
|
||||||
┌─────────────────────┐
|
┌─────────────────────┐
|
||||||
│ │ ← Lots of empty space
|
│ │ ← Lots of empty space
|
||||||
|
|
@ -175,7 +195,12 @@ Rolling window modes (2 & 3) automatically integrate with the `chart_metadata` s
|
||||||
0 100 ct
|
0 100 ct
|
||||||
```
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
**With chart_metadata sensor (enabled):**
|
**With chart_metadata sensor (enabled):**
|
||||||
|
<details>
|
||||||
|
<summary>Show chart illustration: With Chart Metadata</summary>
|
||||||
|
|
||||||
```
|
```
|
||||||
┌─────────────────────┐
|
┌─────────────────────┐
|
||||||
│ ___ │ ← Y-axis fitted to data
|
│ ___ │ ← 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
|
18 28 ct ← Optimal range
|
||||||
```
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
**Requirements:**
|
**Requirements:**
|
||||||
|
|
||||||
- ✅ The `sensor.<home_name>_chart_metadata` must be **enabled** (it's enabled by default!)
|
- ✅ 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:
|
When `highlight_best_price: true`, vertical bands overlay the chart showing detected best price periods:
|
||||||
|
|
||||||
**Example:**
|
**Example:**
|
||||||
|
<details>
|
||||||
|
<summary>Show chart illustration: Best Price Period Highlights</summary>
|
||||||
|
|
||||||
```
|
```
|
||||||
Price
|
Price
|
||||||
│
|
│
|
||||||
|
|
@ -214,6 +244,8 @@ Price
|
||||||
06:00 12:00 18:00
|
06:00 12:00 18:00
|
||||||
```
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
**Features:**
|
**Features:**
|
||||||
- Automatic detection based on your configuration (see [Period Calculation Guide](period-calculation.md))
|
- Automatic detection based on your configuration (see [Period Calculation Guide](period-calculation.md))
|
||||||
- Tooltip shows "Best Price Period" label
|
- Tooltip shows "Best Price Period" label
|
||||||
|
|
@ -227,19 +259,29 @@ Price
|
||||||
### Required for All Modes
|
### Required for All Modes
|
||||||
|
|
||||||
- **[ApexCharts Card](https://github.com/RomRider/apexcharts-card)**: Core visualization library
|
- **[ApexCharts Card](https://github.com/RomRider/apexcharts-card)**: Core visualization library
|
||||||
|
<details>
|
||||||
|
<summary>Show shell command: Prerequisite for All Modes</summary>
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Install via HACS
|
# Install via HACS
|
||||||
HACS → Frontend → Search "ApexCharts Card" → Download
|
HACS → Frontend → Search "ApexCharts Card" → Download
|
||||||
```
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
### Required for Rolling Window Modes Only
|
### Required for Rolling Window Modes Only
|
||||||
|
|
||||||
- **[Config Template Card](https://github.com/iantrich/config-template-card)**: Enables dynamic configuration
|
- **[Config Template Card](https://github.com/iantrich/config-template-card)**: Enables dynamic configuration
|
||||||
|
<details>
|
||||||
|
<summary>Show shell command: Rolling Window Prerequisite</summary>
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Install via HACS
|
# Install via HACS
|
||||||
HACS → Frontend → Search "Config Template Card" → Download
|
HACS → Frontend → Search "Config Template Card" → Download
|
||||||
```
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
**Note:** Fixed day views (`today`, `tomorrow`) work with ApexCharts Card alone!
|
**Note:** Fixed day views (`today`, `tomorrow`) work with ApexCharts Card alone!
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -250,6 +292,9 @@ Price
|
||||||
|
|
||||||
Edit the `colors` array in the generated YAML:
|
Edit the `colors` array in the generated YAML:
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Show YAML: Custom Color Palette</summary>
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
apex_config:
|
apex_config:
|
||||||
colors:
|
colors:
|
||||||
|
|
@ -258,10 +303,15 @@ apex_config:
|
||||||
- "#FF0000" # Change HIGH/VERY_EXPENSIVE color
|
- "#FF0000" # Change HIGH/VERY_EXPENSIVE color
|
||||||
```
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
### Changing Chart Height
|
### Changing Chart Height
|
||||||
|
|
||||||
Add to the card configuration:
|
Add to the card configuration:
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Show YAML: Custom Chart Height</summary>
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
type: custom:apexcharts-card
|
type: custom:apexcharts-card
|
||||||
graph_span: 48h
|
graph_span: 48h
|
||||||
|
|
@ -273,10 +323,15 @@ apex_config:
|
||||||
height: 400 # Adjust height in pixels
|
height: 400 # Adjust height in pixels
|
||||||
```
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
### Combining with Other Cards
|
### Combining with Other Cards
|
||||||
|
|
||||||
Wrap in a vertical stack for dashboard integration:
|
Wrap in a vertical stack for dashboard integration:
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Show YAML: Vertical Stack Integration</summary>
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
type: vertical-stack
|
type: vertical-stack
|
||||||
cards:
|
cards:
|
||||||
|
|
@ -286,11 +341,13 @@ cards:
|
||||||
# ... generated chart config
|
# ... generated chart config
|
||||||
```
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Next Steps
|
## 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
|
- **[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
|
- **[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 |
|
| Verkoopvergoeding | `input_number.verkoopvergoeding` | 0 | 1 | 0.0001 | €/kWh | 0.0205 |
|
||||||
|
|
||||||
<details>
|
<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`:
|
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.
|
These template sensors calculate what you **earn** per kWh when feeding solar power back to the grid.
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary>Show YAML: Template sensors for feed-in with and without saldering</summary>
|
<summary>Show YAML: Feed-In Compensation Sensors</summary>
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
template:
|
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:
|
Now you can use these sensors to make smarter decisions about when to export solar power vs. charge a battery:
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary>Show YAML: Smart export automation</summary>
|
<summary>Show YAML: Feed-In Automation</summary>
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
automation:
|
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:
|
To understand the financial impact of the saldering phase-out, you can create a dashboard comparing both scenarios side by side:
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary>Show YAML: Dashboard comparison card</summary>
|
<summary>Show YAML: Preparing for the End of Saldering</summary>
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
type: entities
|
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:
|
A simple template sensor showing what percentage of your total price is the actual energy cost:
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary>Show YAML: Spot price share template sensor</summary>
|
<summary>Show YAML: Spot Price Share</summary>
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
template:
|
template:
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
# Configuration
|
# 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
|
## Initial Setup
|
||||||
|
|
||||||
|
|
@ -125,7 +127,7 @@ Thresholds are [volatility-adaptive](sensors-trends.md): automatically widened o
|
||||||
|
|
||||||
### Step 9: Chart Data Export (Legacy)
|
### 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
|
## Configuration Options
|
||||||
|
|
||||||
|
|
@ -152,10 +154,15 @@ The integration allows you to choose how average price sensors display their val
|
||||||
#### Why This Matters
|
#### Why This Matters
|
||||||
|
|
||||||
Consider a day with these hourly prices:
|
Consider a day with these hourly prices:
|
||||||
|
<details>
|
||||||
|
<summary>Show example: Mean vs Median</summary>
|
||||||
|
|
||||||
```
|
```
|
||||||
10, 12, 13, 15, 80 ct/kWh
|
10, 12, 13, 15, 80 ct/kWh
|
||||||
```
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
- **Median = 13 ct/kWh** ← "Typical" price (middle value, ignores spike)
|
- **Median = 13 ct/kWh** ← "Typical" price (middle value, ignores spike)
|
||||||
- **Mean = 26 ct/kWh** ← Average cost (spike pulls it up)
|
- **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:
|
**Both values are always available as attributes**, regardless of your display choice:
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Show YAML: Median and Mean Attributes</summary>
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
# These attributes work regardless of display setting:
|
# These attributes work regardless of display setting:
|
||||||
{{ state_attr('sensor.<home_name>_price_today', 'price_median') }}
|
{{ state_attr('sensor.<home_name>_price_today', 'price_median') }}
|
||||||
{{ state_attr('sensor.<home_name>_price_today', 'price_mean') }}
|
{{ state_attr('sensor.<home_name>_price_today', 'price_mean') }}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
This means:
|
This means:
|
||||||
- ✅ You can change the display anytime without breaking automations
|
- ✅ You can change the display anytime without breaking automations
|
||||||
- ✅ Automations can use both values for different purposes
|
- ✅ 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
|
### Example: Seasonal Automation
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Show YAML: Seasonal Runtime Override</summary>
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
automation:
|
automation:
|
||||||
- alias: "Winter: Stricter Best Price Detection"
|
- alias: "Winter: Stricter Best Price Detection"
|
||||||
|
|
@ -272,6 +287,8 @@ automation:
|
||||||
value: 10 # Stricter than default 15%
|
value: 10 # Stricter than default 15%
|
||||||
```
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
### Recorder Optimization (Optional)
|
### Recorder Optimization (Optional)
|
||||||
|
|
||||||
These configuration entities are designed to minimize database impact:
|
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`:
|
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
|
```yaml
|
||||||
recorder:
|
recorder:
|
||||||
exclude:
|
exclude:
|
||||||
|
|
@ -294,6 +314,8 @@ recorder:
|
||||||
- switch.*_peak_price_*
|
- switch.*_peak_price_*
|
||||||
```
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
This is especially useful if:
|
This is especially useful if:
|
||||||
- You rarely change these settings
|
- You rarely change these settings
|
||||||
- You want the smallest possible database footprint
|
- You want the smallest possible database footprint
|
||||||
|
|
|
||||||
|
|
@ -2,12 +2,17 @@
|
||||||
|
|
||||||
Beautiful dashboard layouts using Tibber Prices sensors.
|
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
|
## Basic Price Display Card
|
||||||
|
|
||||||
Simple card showing current price with dynamic color:
|
Simple card showing current price with dynamic color:
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Show YAML: Current Price Card</summary>
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
type: entities
|
type: entities
|
||||||
title: Current Electricity Price
|
title: Current Electricity Price
|
||||||
|
|
@ -21,10 +26,15 @@ entities:
|
||||||
name: Next Price
|
name: Next Price
|
||||||
```
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
## Period Status Cards
|
## Period Status Cards
|
||||||
|
|
||||||
Show when best/peak price periods are active:
|
Show when best/peak price periods are active:
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Show YAML: Best and Peak Period Card</summary>
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
type: horizontal-stack
|
type: horizontal-stack
|
||||||
cards:
|
cards:
|
||||||
|
|
@ -38,12 +48,14 @@ cards:
|
||||||
icon: mdi:alert
|
icon: mdi:alert
|
||||||
```
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
## Custom Button Card Examples
|
## Custom Button Card Examples
|
||||||
|
|
||||||
### Price Level Card
|
### Price Level Card
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary>Show YAML: Price level card with color gradients (custom:button-card)</summary>
|
<summary>Show YAML: Price Level Card</summary>
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
type: custom:button-card
|
type: custom:button-card
|
||||||
|
|
@ -72,7 +84,7 @@ styles:
|
||||||
Optimized for mobile devices:
|
Optimized for mobile devices:
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary>Show YAML: Compact mobile layout</summary>
|
<summary>Show YAML: Optimized for mobile devices</summary>
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
type: vertical-stack
|
type: vertical-stack
|
||||||
|
|
@ -99,7 +111,7 @@ cards:
|
||||||
Full-width layout for desktop:
|
Full-width layout for desktop:
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary>Show YAML: Desktop 3-column grid layout</summary>
|
<summary>Show YAML: Full-width layout for desktop</summary>
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
type: grid
|
type: grid
|
||||||
|
|
@ -134,7 +146,7 @@ cards:
|
||||||
Using the `icon_color` attribute for dynamic colors:
|
Using the `icon_color` attribute for dynamic colors:
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary>Show YAML: Dynamic icon colors with mushroom chips</summary>
|
<summary>Show YAML: Icon Color Integration</summary>
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
type: custom:mushroom-chips-card
|
type: custom:mushroom-chips-card
|
||||||
|
|
@ -161,7 +173,7 @@ See [Icon Colors](icon-colors.md) for detailed color mapping.
|
||||||
Advanced interactive dashboard:
|
Advanced interactive dashboard:
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary>Show YAML: Picture elements interactive dashboard</summary>
|
<summary>Show YAML: Advanced interactive dashboard</summary>
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
type: picture-elements
|
type: picture-elements
|
||||||
|
|
@ -191,7 +203,7 @@ elements:
|
||||||
Automatically list all price sensors:
|
Automatically list all price sensors:
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary>Show YAML: Auto-entities card for all price sensors</summary>
|
<summary>Show YAML: Auto-Entities Sensor List</summary>
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
type: custom:auto-entities
|
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.
|
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?
|
## 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:
|
Dynamic icons work automatically in standard Home Assistant cards:
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Show YAML: Standard Entity Cards</summary>
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
type: entities
|
type: entities
|
||||||
entities:
|
entities:
|
||||||
|
|
@ -46,10 +51,15 @@ entities:
|
||||||
- entity: binary_sensor.<home_name>_best_price_period
|
- entity: binary_sensor.<home_name>_best_price_period
|
||||||
```
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
The icons will update automatically as the sensor states change.
|
The icons will update automatically as the sensor states change.
|
||||||
|
|
||||||
### Glance Card
|
### Glance Card
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Show YAML: Glance Card</summary>
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
type: glance
|
type: glance
|
||||||
entities:
|
entities:
|
||||||
|
|
@ -61,8 +71,13 @@ entities:
|
||||||
name: Best Price
|
name: Best Price
|
||||||
```
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
### Custom Button Card
|
### Custom Button Card
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Show YAML: Custom Button Card</summary>
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
type: custom:button-card
|
type: custom:button-card
|
||||||
entity: sensor.<home_name>_current_price_level
|
entity: sensor.<home_name>_current_price_level
|
||||||
|
|
@ -71,8 +86,13 @@ show_state: true
|
||||||
# Icon updates automatically - no need to specify it!
|
# Icon updates automatically - no need to specify it!
|
||||||
```
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
### Mushroom Entity Card
|
### Mushroom Entity Card
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Show YAML: Mushroom Entity Card</summary>
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
type: custom:mushroom-entity-card
|
type: custom:mushroom-entity-card
|
||||||
entity: sensor.<home_name>_today_s_price_volatility
|
entity: sensor.<home_name>_today_s_price_volatility
|
||||||
|
|
@ -80,12 +100,17 @@ name: Price Volatility
|
||||||
# Icon changes automatically based on volatility level
|
# Icon changes automatically based on volatility level
|
||||||
```
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
## Overriding Dynamic Icons
|
## Overriding Dynamic Icons
|
||||||
|
|
||||||
If you want to use a fixed icon instead of the dynamic one:
|
If you want to use a fixed icon instead of the dynamic one:
|
||||||
|
|
||||||
### In Entity Cards
|
### In Entity Cards
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Show YAML: Fixed Icon in Entity Card</summary>
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
type: entities
|
type: entities
|
||||||
entities:
|
entities:
|
||||||
|
|
@ -93,8 +118,13 @@ entities:
|
||||||
icon: mdi:lightning-bolt # Fixed icon, won't change
|
icon: mdi:lightning-bolt # Fixed icon, won't change
|
||||||
```
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
### In Custom Button Card
|
### In Custom Button Card
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Show YAML: Fixed Icon in Button Card</summary>
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
type: custom:button-card
|
type: custom:button-card
|
||||||
entity: sensor.<home_name>_current_price_rating
|
entity: sensor.<home_name>_current_price_rating
|
||||||
|
|
@ -103,12 +133,17 @@ icon: mdi:chart-line # Fixed icon overrides dynamic behavior
|
||||||
show_state: true
|
show_state: true
|
||||||
```
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
## Combining with Dynamic Colors
|
## Combining with Dynamic Colors
|
||||||
|
|
||||||
Dynamic icons work great together with dynamic colors! See the **[Dynamic Icon Colors Guide](icon-colors.md)** for examples.
|
Dynamic icons work great together with dynamic colors! See the **[Dynamic Icon Colors Guide](icon-colors.md)** for examples.
|
||||||
|
|
||||||
**Example: Dynamic icon AND color**
|
**Example: Dynamic icon AND color**
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Show YAML: Dynamic Icon and Color</summary>
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
type: custom:button-card
|
type: custom:button-card
|
||||||
entity: sensor.<home_name>_current_price_level
|
entity: sensor.<home_name>_current_price_level
|
||||||
|
|
@ -123,6 +158,8 @@ styles:
|
||||||
]]]
|
]]]
|
||||||
```
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
This gives you both:
|
This gives you both:
|
||||||
|
|
||||||
- ✅ Different icon based on state (e.g., cash-plus when cheap, cash-remove when expensive)
|
- ✅ 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
|
## 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?
|
### How do I run dishwasher during cheap period?
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Show YAML: Dishwasher During Cheap Period</summary>
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
automation:
|
automation:
|
||||||
- alias: "Dishwasher during Best Price"
|
- alias: "Dishwasher during Best Price"
|
||||||
|
|
@ -132,12 +137,17 @@ automation:
|
||||||
entity_id: switch.dishwasher
|
entity_id: switch.dishwasher
|
||||||
```
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
See [Automation Examples](automation-examples.md) for more recipes.
|
See [Automation Examples](automation-examples.md) for more recipes.
|
||||||
|
|
||||||
### Can I avoid peak prices automatically?
|
### Can I avoid peak prices automatically?
|
||||||
|
|
||||||
Yes! Use Peak Price Period binary sensor:
|
Yes! Use Peak Price Period binary sensor:
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Show YAML: Avoid Peak Prices Automatically</summary>
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
automation:
|
automation:
|
||||||
- alias: "Disable charging during peak prices"
|
- alias: "Disable charging during peak prices"
|
||||||
|
|
@ -151,6 +161,8 @@ automation:
|
||||||
entity_id: switch.ev_charger
|
entity_id: switch.ev_charger
|
||||||
```
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
💡 **Still need help?**
|
💡 **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.
|
> **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?
|
## 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:**
|
**Example of when NOT to use icon_color:**
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Show YAML: Conversion vs Direct State Logic</summary>
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
# ❌ DON'T: Converting icon_color requires if/else anyway
|
# ❌ DON'T: Converting icon_color requires if/else anyway
|
||||||
card:
|
card:
|
||||||
|
|
@ -87,6 +92,8 @@ card:
|
||||||
]]]
|
]]]
|
||||||
```
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
The advantage of `icon_color` is simplicity - if you need complex logic, you lose that advantage.
|
The advantage of `icon_color` is simplicity - if you need complex logic, you lose that advantage.
|
||||||
|
|
||||||
## How to Use icon_color in Your Dashboard
|
## 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**
|
**Example: Icon color only**
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Show YAML: Icon color only</summary>
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
type: custom:button-card
|
type: custom:button-card
|
||||||
entity: sensor.<home_name>_current_price_level
|
entity: sensor.<home_name>_current_price_level
|
||||||
|
|
@ -111,8 +121,13 @@ styles:
|
||||||
]]]
|
]]]
|
||||||
```
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
**Example: Icon AND state value with same color**
|
**Example: Icon AND state value with same color**
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Show YAML: Button Card Icon and State Text</summary>
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
type: custom:button-card
|
type: custom:button-card
|
||||||
entity: sensor.<home_name>_current_price_level
|
entity: sensor.<home_name>_current_price_level
|
||||||
|
|
@ -133,12 +148,14 @@ styles:
|
||||||
- font-weight: bold
|
- font-weight: bold
|
||||||
```
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
### Method 2: Entities Card with card_mod
|
### Method 2: Entities Card with card_mod
|
||||||
|
|
||||||
Use Home Assistant's built-in entities card with card_mod for icon and state colors:
|
Use Home Assistant's built-in entities card with card_mod for icon and state colors:
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary>Show YAML: Entities card with card_mod</summary>
|
<summary>Show YAML: Entities Card with card-mod</summary>
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
type: entities
|
type: entities
|
||||||
|
|
@ -164,6 +181,9 @@ The [Mushroom cards](https://github.com/piitaya/lovelace-mushroom) support card_
|
||||||
|
|
||||||
**Icon color only:**
|
**Icon color only:**
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Show YAML: Mushroom Icon Color Only</summary>
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
type: custom:mushroom-entity-card
|
type: custom:mushroom-entity-card
|
||||||
entity: binary_sensor.<home_name>_best_price_period
|
entity: binary_sensor.<home_name>_best_price_period
|
||||||
|
|
@ -176,10 +196,12 @@ card_mod:
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
**Icon and state value:**
|
**Icon and state value:**
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary>Show YAML: Mushroom card — icon and state value with same color</summary>
|
<summary>Show YAML: Icon and state value</summary>
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
type: custom:mushroom-entity-card
|
type: custom:mushroom-entity-card
|
||||||
|
|
@ -200,7 +222,7 @@ card_mod:
|
||||||
Combine multiple sensors with dynamic colors:
|
Combine multiple sensors with dynamic colors:
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary>Show YAML: Glance card with card_mod for multiple sensors</summary>
|
<summary>Show YAML: Multi-Sensor Dynamic Colors</summary>
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
type: glance
|
type: glance
|
||||||
|
|
@ -228,7 +250,7 @@ card_mod:
|
||||||
Here's a complete example combining multiple sensors with dynamic colors:
|
Here's a complete example combining multiple sensors with dynamic colors:
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary>Show YAML: Complete dashboard with dynamic colors for all sensor types</summary>
|
<summary>Show YAML: Complete Dashboard Example</summary>
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
type: vertical-stack
|
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`):
|
Define custom colors in your theme configuration (`themes.yaml`):
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Show YAML: Theme-Level Color Overrides</summary>
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
my_custom_theme:
|
my_custom_theme:
|
||||||
# Override standard variables
|
# Override standard variables
|
||||||
|
|
@ -343,6 +368,8 @@ my_custom_theme:
|
||||||
info-color: "#0288D1" # Custom blue
|
info-color: "#0288D1" # Custom blue
|
||||||
```
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
The `icon_color` attribute will automatically use your custom theme colors.
|
The `icon_color` attribute will automatically use your custom theme colors.
|
||||||
|
|
||||||
#### Option 2: Interpret State Value Directly
|
#### 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**
|
**Example: Custom colors for price level**
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary>Show YAML: Price level — 5-color JavaScript mapping</summary>
|
<summary>Show YAML: Custom colors for price level</summary>
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
type: custom:button-card
|
type: custom:button-card
|
||||||
|
|
@ -379,7 +406,7 @@ styles:
|
||||||
**Example: Custom colors for binary sensor**
|
**Example: Custom colors for binary sensor**
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary>Show YAML: Binary sensor — on/off color + background highlight</summary>
|
<summary>Show YAML: Custom colors for binary sensor</summary>
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
type: custom:button-card
|
type: custom:button-card
|
||||||
|
|
@ -406,7 +433,7 @@ styles:
|
||||||
**Example: Custom colors for volatility**
|
**Example: Custom colors for volatility**
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary>Show YAML: Volatility sensor — 4-level color mapping</summary>
|
<summary>Show YAML: Custom colors for volatility</summary>
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
type: custom:button-card
|
type: custom:button-card
|
||||||
|
|
@ -431,7 +458,7 @@ styles:
|
||||||
**Example: Custom colors for price rating**
|
**Example: Custom colors for price rating**
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary>Show YAML: Price rating — 3-state color mapping</summary>
|
<summary>Show YAML: Custom colors for price rating</summary>
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
type: custom:button-card
|
type: custom:button-card
|
||||||
|
|
|
||||||
|
|
@ -16,9 +16,14 @@
|
||||||
2. Go to **Integrations**
|
2. Go to **Integrations**
|
||||||
3. Click the **⋮** menu (top right) → **Custom repositories**
|
3. Click the **⋮** menu (top right) → **Custom repositories**
|
||||||
4. Add the repository URL:
|
4. Add the repository URL:
|
||||||
|
<details>
|
||||||
|
<summary>Show example: Repository URL</summary>
|
||||||
|
|
||||||
```
|
```
|
||||||
https://github.com/jpawlowski/hass.tibber_prices
|
https://github.com/jpawlowski/hass.tibber_prices
|
||||||
```
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
Category: **Integration**
|
Category: **Integration**
|
||||||
5. Click **Add**
|
5. Click **Add**
|
||||||
6. Find **Tibber Price Information & Ratings** in the integration list
|
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
|
1. Download the [latest release](https://github.com/jpawlowski/hass.tibber_prices/releases/latest) from GitHub
|
||||||
2. Extract the `custom_components/tibber_prices/` folder
|
2. Extract the `custom_components/tibber_prices/` folder
|
||||||
3. Copy it to your Home Assistant `config/custom_components/` directory:
|
3. Copy it to your Home Assistant `config/custom_components/` directory:
|
||||||
|
<details>
|
||||||
|
<summary>Show example: Folder Structure</summary>
|
||||||
|
|
||||||
```
|
```
|
||||||
config/
|
config/
|
||||||
└── custom_components/
|
└── custom_components/
|
||||||
|
|
@ -52,6 +60,8 @@ If you prefer not to use HACS:
|
||||||
├── binary_sensor/
|
├── binary_sensor/
|
||||||
└── ...
|
└── ...
|
||||||
```
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
4. **Restart Home Assistant**
|
4. **Restart Home Assistant**
|
||||||
5. Continue with [Configuration](configuration.md)
|
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
|
- **[Sensors](sensors-overview.md)** - Available sensors, their states, and attributes
|
||||||
- **[Dynamic Icons](dynamic-icons.md)** - State-based automatic icon changes
|
- **[Dynamic Icons](dynamic-icons.md)** - State-based automatic icon changes
|
||||||
- **[Dynamic Icon Colors](icon-colors.md)** - Using icon_color attribute for color-coded dashboards
|
- **[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
|
- **[Chart Examples](chart-examples.md)** - ✨ ApexCharts visualizations with screenshots
|
||||||
- **[Automation Examples](automation-examples.md)** - Ready-to-use automation recipes
|
- **[Automation Examples](automation-examples.md)** - Ready-to-use automation recipes
|
||||||
- **[Troubleshooting](troubleshooting.md)** - Common issues and solutions
|
- **[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.
|
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
|
## Table of Contents
|
||||||
|
|
||||||
|
|
@ -55,6 +57,9 @@ The integration sets different **initial defaults** because the features serve d
|
||||||
|
|
||||||
### Example Timeline
|
### Example Timeline
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Show example: Timeline</summary>
|
||||||
|
|
||||||
```
|
```
|
||||||
00:00 ████████████████ Best Price Period (cheap prices)
|
00:00 ████████████████ Best Price Period (cheap prices)
|
||||||
04:00 ░░░░░░░░░░░░░░░░ Normal
|
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)
|
20:00 ████████████████ Best Price Period (cheap prices)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## How It Works
|
## 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?
|
**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
|
Daily MIN: 20 ct/kWh
|
||||||
Flexibility: 15% (default)
|
Flexibility: 15% (default)
|
||||||
→ Search for times ≤ 23 ct/kWh (20 + 15%)
|
→ Search for times ≤ 23 ct/kWh (20 + 15%)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
**Peak Price:** How much LESS than the daily maximum can a price be?
|
**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
|
Daily MAX: 40 ct/kWh
|
||||||
Flexibility: -15% (default)
|
Flexibility: -15% (default)
|
||||||
→ Search for times ≥ 34 ct/kWh (40 - 15%)
|
→ 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.
|
**Why flexibility?** Prices rarely stay at exactly MIN/MAX. Flexibility lets you capture realistic time windows.
|
||||||
|
|
||||||
#### 2. Ensure Quality (Distance from Average)
|
#### 2. Ensure Quality (Distance from Average)
|
||||||
|
|
||||||
Periods must be meaningfully different from the daily average:
|
Periods must be meaningfully different from the daily average:
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Show example: Distance from Average</summary>
|
||||||
|
|
||||||
```
|
```
|
||||||
Daily AVG: 30 ct/kWh
|
Daily AVG: 30 ct/kWh
|
||||||
Minimum distance: 5% (default)
|
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%)
|
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.
|
**Why?** This prevents marking mediocre times as "best" just because they're slightly below average.
|
||||||
|
|
||||||
#### 3. Check Duration
|
#### 3. Check Duration
|
||||||
|
|
||||||
Periods must be long enough to be practical:
|
Periods must be long enough to be practical:
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Show example: Minimum Period Duration</summary>
|
||||||
|
|
||||||
```
|
```
|
||||||
Default: 60 minutes minimum
|
Default: 60 minutes minimum
|
||||||
|
|
||||||
|
|
@ -155,6 +180,8 @@ Default: 60 minutes minimum
|
||||||
90-minute period → Kept ✓
|
90-minute period → Kept ✓
|
||||||
```
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
#### 4. Apply Optional Filters
|
#### 4. Apply Optional Filters
|
||||||
|
|
||||||
You can optionally require:
|
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:
|
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
|
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
|
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
|
Result: Continuous period 00:00-01:15 instead of split periods
|
||||||
```
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
**Important:**
|
**Important:**
|
||||||
- Original prices are always preserved (min/max/avg show real values)
|
- Original prices are always preserved (min/max/avg show real values)
|
||||||
- Smoothing only affects which intervals are combined into periods
|
- 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:**
|
**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
|
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
|
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)
|
06:00-11:00 (5h)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Configuration Guide
|
## Configuration Guide
|
||||||
|
|
@ -208,11 +245,16 @@ Peak Price (-15% flex = ≥29.75 ct):
|
||||||
**Default:** 15% (Best Price), -15% (Peak Price)
|
**Default:** 15% (Best Price), -15% (Peak Price)
|
||||||
**Range:** 0-100%
|
**Range:** 0-100%
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Show YAML: Flexibility</summary>
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
best_price_flex: 15 # Can be up to 15% more expensive than daily MIN
|
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
|
peak_price_flex: -15 # Can be up to 15% less expensive than daily MAX
|
||||||
```
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
**When to adjust:**
|
**When to adjust:**
|
||||||
|
|
||||||
- **Increase (20-25%)** → Find more/longer periods
|
- **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)
|
**Default:** 60 minutes (Best Price), 30 minutes (Peak Price)
|
||||||
**Range:** 15-240 minutes
|
**Range:** 15-240 minutes
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Show YAML: Minimum Period Length</summary>
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
best_price_min_period_length: 60
|
best_price_min_period_length: 60
|
||||||
peak_price_min_period_length: 30
|
peak_price_min_period_length: 30
|
||||||
```
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
**When to adjust:**
|
**When to adjust:**
|
||||||
|
|
||||||
- **Increase (90-120 min)** → Only show longer periods (e.g., for heat pump cycles)
|
- **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%
|
**Default:** 5%
|
||||||
**Range:** 0-20%
|
**Range:** 0-20%
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Show YAML: Distance from Average</summary>
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
best_price_min_distance_from_avg: 5
|
best_price_min_distance_from_avg: 5
|
||||||
peak_price_min_distance_from_avg: 5
|
peak_price_min_distance_from_avg: 5
|
||||||
```
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
**When to adjust:**
|
**When to adjust:**
|
||||||
|
|
||||||
- **Increase (5-10%)** → Only show clearly better times
|
- **Increase (5-10%)** → Only show clearly better times
|
||||||
|
|
@ -262,11 +314,16 @@ peak_price_min_distance_from_avg: 5
|
||||||
**Default:** `any` (disabled)
|
**Default:** `any` (disabled)
|
||||||
**Options:** `any` | `cheap` | `very_cheap` (Best Price) | `expensive` | `very_expensive` (Peak Price)
|
**Options:** `any` | `cheap` | `very_cheap` (Best Price) | `expensive` | `very_expensive` (Peak Price)
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Show YAML: Level Filter</summary>
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
best_price_max_level: any # Show any period below average
|
best_price_max_level: any # Show any period below average
|
||||||
best_price_max_level: cheap # Only show if at least one interval is CHEAP
|
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"
|
**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.
|
**ℹ️ 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)
|
**Default:** 0 (strict)
|
||||||
**Range:** 0-10
|
**Range:** 0-10
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Show YAML: Gap Tolerance</summary>
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
best_price_max_level: cheap
|
best_price_max_level: cheap
|
||||||
best_price_max_level_gap_count: 2 # Allow up to 2 NORMAL intervals per period
|
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"
|
**Use case:** "Don't split periods just because one interval isn't perfectly CHEAP"
|
||||||
|
|
||||||
### Tweaking Strategy: What to Adjust First?
|
### 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:
|
If you're not finding enough periods:
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Show YAML: Relaxation Defaults</summary>
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
enable_min_periods_best: true # Already default!
|
enable_min_periods_best: true # Already default!
|
||||||
min_periods_best: 2 # Already default!
|
min_periods_best: 2 # Already default!
|
||||||
relaxation_attempts_best: 11 # 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.
|
**Why start here?** Relaxation automatically finds the right balance for each day. Much easier than manual tuning.
|
||||||
|
|
||||||
#### 2. **Adjust Period Length (Simple)**
|
#### 2. **Adjust Period Length (Simple)**
|
||||||
|
|
||||||
If periods are too short/long for your use case:
|
If periods are too short/long for your use case:
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Show YAML: Longer or Shorter Periods</summary>
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
best_price_min_period_length: 90 # Increase from 60 for longer periods
|
best_price_min_period_length: 90 # Increase from 60 for longer periods
|
||||||
# OR
|
# OR
|
||||||
best_price_min_period_length: 45 # Decrease from 60 for shorter periods
|
best_price_min_period_length: 45 # Decrease from 60 for shorter periods
|
||||||
```
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
**Safe to change:** This only affects duration, not price selection logic.
|
**Safe to change:** This only affects duration, not price selection logic.
|
||||||
|
|
||||||
#### 3. **Fine-tune Flexibility (Moderate)**
|
#### 3. **Fine-tune Flexibility (Moderate)**
|
||||||
|
|
||||||
If you consistently want more/fewer periods:
|
If you consistently want more/fewer periods:
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Show YAML: Flexibility Tuning</summary>
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
best_price_flex: 20 # Increase from 15% for more periods
|
best_price_flex: 20 # Increase from 15% for more periods
|
||||||
# OR
|
# OR
|
||||||
best_price_flex: 10 # Decrease from 15% for stricter selection
|
best_price_flex: 10 # Decrease from 15% for stricter selection
|
||||||
```
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
**⚠️ Watch out:** Values >25% may conflict with distance filter. Use relaxation instead.
|
**⚠️ Watch out:** Values >25% may conflict with distance filter. Use relaxation instead.
|
||||||
|
|
||||||
#### 4. **Adjust Distance from Average (Advanced)**
|
#### 4. **Adjust Distance from Average (Advanced)**
|
||||||
|
|
||||||
Only if periods seem "mediocre" (not really cheap/expensive):
|
Only if periods seem "mediocre" (not really cheap/expensive):
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Show YAML: Distance from Average</summary>
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
best_price_min_distance_from_avg: 10 # Increase from 5% for stricter quality
|
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.
|
**⚠️ Careful:** High values (>10%) can make it impossible to find periods on flat price days.
|
||||||
|
|
||||||
#### 5. **Enable Level Filter (Expert)**
|
#### 5. **Enable Level Filter (Expert)**
|
||||||
|
|
||||||
Only if you want absolute quality requirements:
|
Only if you want absolute quality requirements:
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Show YAML: Strict Level Filter</summary>
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
best_price_max_level: cheap # Only show objectively CHEAP periods
|
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!**
|
**⚠️ Very strict:** Many days may have zero qualifying periods. **Always enable relaxation when using this!**
|
||||||
|
|
||||||
### Common Mistakes to Avoid
|
### 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:**
|
**Configuration:**
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Show YAML: Simple Best Price</summary>
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
# Use defaults - no configuration needed!
|
# Use defaults - no configuration needed!
|
||||||
best_price_flex: 15 # (default)
|
best_price_flex: 15 # (default)
|
||||||
|
|
@ -389,6 +479,8 @@ best_price_min_period_length: 60 # (default)
|
||||||
best_price_min_distance_from_avg: 5 # (default)
|
best_price_min_distance_from_avg: 5 # (default)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
**What you get:**
|
**What you get:**
|
||||||
|
|
||||||
- 1-3 periods per day with prices ≤ MIN + 15%
|
- 1-3 periods per day with prices ≤ MIN + 15%
|
||||||
|
|
@ -397,6 +489,9 @@ best_price_min_distance_from_avg: 5 # (default)
|
||||||
|
|
||||||
**Automation example:**
|
**Automation example:**
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Show YAML: Dishwasher in Best Price Period</summary>
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
automation:
|
automation:
|
||||||
- trigger:
|
- trigger:
|
||||||
|
|
@ -409,6 +504,8 @@ automation:
|
||||||
entity_id: switch.dishwasher
|
entity_id: switch.dishwasher
|
||||||
```
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Troubleshooting
|
## 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.
|
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
|
```yaml
|
||||||
min_periods_configured: 2
|
min_periods_configured: 2
|
||||||
periods_found_total: 1
|
periods_found_total: 1
|
||||||
flat_days_detected: 1 # Uniform prices today → 1 period is the right answer
|
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.
|
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`):**
|
**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:
|
Relaxation tried all configured attempts but couldn't reach your target. Options:
|
||||||
|
|
||||||
1. **Increase relaxation attempts** (tries more flexibility levels before giving up)
|
1. **Increase relaxation attempts** (tries more flexibility levels before giving up)
|
||||||
|
<details>
|
||||||
|
<summary>Show YAML example (increase relaxation attempts)</summary>
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
relaxation_attempts_best: 12 # Default: 11
|
relaxation_attempts_best: 12 # Default: 11
|
||||||
```
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
2. **Reduce minimum period count**
|
2. **Reduce minimum period count**
|
||||||
|
<details>
|
||||||
|
<summary>Show YAML example (reduce min periods)</summary>
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
min_periods_best: 1 # Only require 1 period per day
|
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
|
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:**
|
**Common Solutions:**
|
||||||
|
|
||||||
1. **Check if relaxation is enabled**
|
1. **Check if relaxation is enabled**
|
||||||
|
<details>
|
||||||
|
<summary>Show YAML example (relaxation enabled)</summary>
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
enable_min_periods_best: true # Should be true (default)
|
enable_min_periods_best: true # Should be true (default)
|
||||||
min_periods_best: 2 # Try to find at least 2 periods
|
min_periods_best: 2 # Try to find at least 2 periods
|
||||||
```
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
2. **If still no periods, check filters**
|
2. **If still no periods, check filters**
|
||||||
- Look at sensor attributes: `relaxation_active` and `relaxation_level`
|
- Look at sensor attributes: `relaxation_active` and `relaxation_level`
|
||||||
- If relaxation exhausted all attempts: Filters too strict or flat price day
|
- If relaxation exhausted all attempts: Filters too strict or flat price day
|
||||||
|
|
||||||
3. **Try increasing flexibility slightly**
|
3. **Try increasing flexibility slightly**
|
||||||
|
<details>
|
||||||
|
<summary>Show YAML example (increase flexibility)</summary>
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
best_price_flex: 20 # Increase from default 15%
|
best_price_flex: 20 # Increase from default 15%
|
||||||
```
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
4. **Or reduce period length requirement**
|
4. **Or reduce period length requirement**
|
||||||
|
<details>
|
||||||
|
<summary>Show YAML example (reduce period length)</summary>
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
best_price_min_period_length: 45 # Reduce from default 60 minutes
|
best_price_min_period_length: 45 # Reduce from default 60 minutes
|
||||||
```
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
### Periods Split Into Small Pieces
|
### Periods Split Into Small Pieces
|
||||||
|
|
||||||
**Symptom:** Many short periods instead of one long period
|
**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:**
|
**Common Solutions:**
|
||||||
|
|
||||||
1. **If using level filter, add gap tolerance**
|
1. **If using level filter, add gap tolerance**
|
||||||
|
<details>
|
||||||
|
<summary>Show YAML example (level gap tolerance)</summary>
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
best_price_max_level: cheap
|
best_price_max_level: cheap
|
||||||
best_price_max_level_gap_count: 2 # Allow 2 NORMAL intervals
|
best_price_max_level_gap_count: 2 # Allow 2 NORMAL intervals
|
||||||
```
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
2. **Slightly increase flexibility**
|
2. **Slightly increase flexibility**
|
||||||
|
<details>
|
||||||
|
<summary>Show YAML example (wider flexibility)</summary>
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
best_price_flex: 20 # From 15% → captures wider price range
|
best_price_flex: 20 # From 15% → captures wider price range
|
||||||
```
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
3. **Check for price spikes**
|
3. **Check for price spikes**
|
||||||
- Automatic smoothing should handle this
|
- Automatic smoothing should handle this
|
||||||
- Check attribute: `period_interval_smoothed_count`
|
- 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:**
|
**Key attributes to check:**
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary>Show YAML: Sensor attribute reference for best_price_period</summary>
|
<summary>Show YAML: Key attributes to check</summary>
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
# Entity: binary_sensor.<home_name>_best_price_period
|
# 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:
|
These two values together quickly show whether the calculation achieved its goal:
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Show YAML: Configured Target vs Found Periods</summary>
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
min_periods_configured: 2 # You asked for 2 periods per day
|
min_periods_configured: 2 # You asked for 2 periods per day
|
||||||
periods_found_total: 6 # 3 days × 2 periods = fully satisfied ✅
|
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
|
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.
|
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`**
|
**`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):
|
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
|
```yaml
|
||||||
min_periods_configured: 2
|
min_periods_configured: 2
|
||||||
periods_found_total: 1
|
periods_found_total: 1
|
||||||
flat_days_detected: 1 # ← This explains why you got 1 instead of 2
|
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.
|
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.
|
**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:
|
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
|
```yaml
|
||||||
min_periods_configured: 2
|
min_periods_configured: 2
|
||||||
periods_found_total: 1
|
periods_found_total: 1
|
||||||
relaxation_incomplete: true # ← Relaxation tried everything, still short
|
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:
|
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
|
- Reducing `min_periods_best` to 1
|
||||||
- Increasing `relaxation_attempts_best`
|
- Increasing `relaxation_attempts_best`
|
||||||
|
|
@ -600,7 +752,7 @@ This is **mathematically correct behavior** caused by how electricity prices are
|
||||||
**Example:**
|
**Example:**
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary>Show YAML: How identical prices can flip between best/peak across midnight</summary>
|
<summary>Show YAML: Midnight Price Classification Changes</summary>
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
# Day 1 (low volatility, narrow range)
|
# 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:
|
Check the volatility sensors to understand if a period flip is meaningful:
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Show YAML: Daily Volatility Check</summary>
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
# Check daily volatility (available in integration)
|
# Check daily volatility (available in integration)
|
||||||
sensor.<home_name>_today_s_price_volatility: 8.2% # Low volatility
|
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
|
# - Consider ignoring period classification on such days
|
||||||
```
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
**Handling in Automations:**
|
**Handling in Automations:**
|
||||||
|
|
||||||
You can make your automations volatility-aware:
|
You can make your automations volatility-aware:
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary>Show YAML: 3 automation strategies for volatility-aware period control</summary>
|
<summary>Show YAML: Volatility-Aware Automation</summary>
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
# Option 1: Only act on high-volatility days
|
# Option 1: Only act on high-volatility days
|
||||||
|
|
@ -702,6 +859,9 @@ automation:
|
||||||
|
|
||||||
Each period sensor exposes day volatility and price statistics:
|
Each period sensor exposes day volatility and price statistics:
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Show YAML: Per-Period Volatility Attributes</summary>
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
binary_sensor.<home_name>_best_price_period:
|
binary_sensor.<home_name>_best_price_period:
|
||||||
day_volatility_%: 8.2 # Volatility % of the period's day
|
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
|
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?"
|
These attributes allow automations to check: "Is the classification meaningful on this particular day?"
|
||||||
|
|
||||||
**Summary:**
|
**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:
|
For advanced configuration patterns and technical deep-dive, see:
|
||||||
|
|
||||||
- [Automation Examples](./automation-examples.md) - Real-world automation patterns
|
- [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
|
### Quick Reference
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
# Understanding Relaxation
|
# 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.
|
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
|
## How to Enable
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Show YAML: Enable Relaxation</summary>
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
enable_min_periods_best: true
|
enable_min_periods_best: true
|
||||||
min_periods_best: 2 # Try to find at least 2 periods per day
|
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)
|
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!
|
**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.
|
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**:
|
**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 1: Finds 2 periods with flex 15% (original) → No relaxation needed
|
||||||
Day 2: Needs flex 21% + level=any → Uses relaxed settings
|
Day 2: Needs flex 21% + level=any → Uses relaxed settings
|
||||||
Day 3: Finds 2 periods with flex 15% (original) → No relaxation needed
|
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).
|
**Why?** Price patterns vary daily. Some days have clear cheap/expensive windows (strict filters work), others don't (relaxation needed).
|
||||||
|
|
||||||
## Diagnosing Relaxation Behavior
|
## Diagnosing Relaxation Behavior
|
||||||
|
|
||||||
Check the period sensor attributes to understand what happened:
|
Check the period sensor attributes to understand what happened:
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Show YAML: Diagnosing Relaxation Behavior</summary>
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
# Entity: binary_sensor.<home_name>_best_price_period
|
# 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
|
periods_found_total: 3 # What was actually found
|
||||||
```
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
| Attribute | Meaning |
|
| Attribute | Meaning |
|
||||||
|-----------|---------|
|
|-----------|---------|
|
||||||
| `relaxation_active: false` | Original filters were sufficient |
|
| `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-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_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 | ✅ |
|
| <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
|
||||||
|
|
||||||
### Binary Sensors
|
### Binary Sensors
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
# Average & Statistics Sensors
|
# 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.
|
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:**
|
**Why two values matter:**
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Show YAML: Why two values matter</summary>
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
# Example price data for one day:
|
# Example price data for one day:
|
||||||
# Prices: 10, 12, 13, 15, 80 ct/kWh (one extreme spike)
|
# 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)
|
# 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.
|
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
|
## 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:
|
Both `price_mean` and `price_median` are always available as attributes:
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Show YAML: Mean and Median in Automations</summary>
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
# Example: Get both values regardless of display setting
|
# Example: Get both values regardless of display setting
|
||||||
sensor:
|
sensor:
|
||||||
|
|
@ -73,6 +83,8 @@ sensor:
|
||||||
{% endif %}
|
{% endif %}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
## Practical Examples
|
## Practical Examples
|
||||||
|
|
||||||
**Example 1: Smart dishwasher control**
|
**Example 1: Smart dishwasher control**
|
||||||
|
|
@ -80,7 +92,7 @@ sensor:
|
||||||
Run dishwasher only when price is significantly below the daily typical level:
|
Run dishwasher only when price is significantly below the daily typical level:
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary>Show YAML: Automation — start dishwasher when cheap</summary>
|
<summary>Show YAML: Practical Examples</summary>
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
automation:
|
automation:
|
||||||
|
|
@ -108,7 +120,7 @@ automation:
|
||||||
Use mean for actual cost calculations:
|
Use mean for actual cost calculations:
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary>Show YAML: Automation — cost-aware heating control</summary>
|
<summary>Show YAML: Mean for Cost Calculations</summary>
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
automation:
|
automation:
|
||||||
|
|
@ -135,7 +147,7 @@ automation:
|
||||||
Use trailing average to understand recent price trends:
|
Use trailing average to understand recent price trends:
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary>Show YAML: Automation — EV charging based on rolling average</summary>
|
<summary>Show YAML: Practical Examples</summary>
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
automation:
|
automation:
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,20 @@
|
||||||
# Energy & Tax Attributes
|
# 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:
|
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
|
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.
|
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
|
:::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.
|
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>
|
<details>
|
||||||
<summary>Show YAML: Automation — solar export or consume decision</summary>
|
<summary>Show YAML: Solar Feed-In and Net Metering</summary>
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
# Example: Decide whether to export solar power or consume it
|
# 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:
|
Understand how your electricity price is structured — useful for comparing across days or spotting trends in market prices vs. fees:
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary>Show YAML: Template sensor — electricity tax share percentage</summary>
|
<summary>Show YAML: Price Composition Analysis</summary>
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
# Template sensor showing tax share
|
# Template sensor showing tax share
|
||||||
|
|
@ -87,6 +94,9 @@ template:
|
||||||
|
|
||||||
Show users how today's average price splits into energy vs. tax:
|
Show users how today's average price splits into energy vs. tax:
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Show YAML: Daily Cost Breakdown</summary>
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
# Mushroom chips card showing the split
|
# Mushroom chips card showing the split
|
||||||
type: custom:mushroom-chips-card
|
type: custom:mushroom-chips-card
|
||||||
|
|
@ -101,10 +111,12 @@ chips:
|
||||||
🏛️ {{ state_attr('sensor.<home_name>_price_today', 'tax_mean') | round(1) }} ct
|
🏛️ {{ state_attr('sensor.<home_name>_price_today', 'tax_mean') | round(1) }} ct
|
||||||
```
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
## Country-Specific Calculations
|
## 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.
|
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
|
## 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.
|
> **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.
|
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:**
|
**Example Usage:**
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Show YAML: Example Usage</summary>
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
# ApexCharts card consuming the sensor
|
# ApexCharts card consuming the sensor
|
||||||
type: custom:apexcharts-card
|
type: custom:apexcharts-card
|
||||||
|
|
@ -163,10 +168,15 @@ series:
|
||||||
return entity.attributes.data;
|
return entity.attributes.data;
|
||||||
```
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
**Migration Path:**
|
**Migration Path:**
|
||||||
|
|
||||||
If you're currently using this sensor, consider migrating to the action:
|
If you're currently using this sensor, consider migrating to the action:
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Show YAML: Chart Data Export</summary>
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
# Old approach (sensor)
|
# Old approach (sensor)
|
||||||
- service: apexcharts_card.update
|
- 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
|
output_format: array_of_objects
|
||||||
response_variable: chart_data
|
response_variable: chart_data
|
||||||
```
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
# Ratings & Levels
|
# 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.
|
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:
|
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%
|
difference = ((current_price - trailing_avg) / abs(trailing_avg)) × 100%
|
||||||
```
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
This percentage is then classified:
|
This percentage is then classified:
|
||||||
|
|
||||||
| Rating | Condition (default) | Meaning |
|
| 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):
|
**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
|
```yaml
|
||||||
# ✅ Correct — language-independent
|
# ✅ Correct — language-independent
|
||||||
condition:
|
condition:
|
||||||
|
|
@ -100,6 +110,8 @@ condition:
|
||||||
state: "Low" # "Niedrig" in German, "Lav" in Norwegian...
|
state: "Low" # "Niedrig" in German, "Lav" in Norwegian...
|
||||||
```
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
### Configuration
|
### Configuration
|
||||||
|
|
||||||
Rating thresholds can be adjusted in the options flow:
|
Rating thresholds can be adjusted in the options flow:
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
# Timing Sensors
|
# 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.
|
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
|
### Show Countdown to Next Cheap Window
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Show YAML: Countdown to Next Cheap Window</summary>
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
type: custom:mushroom-entity-card
|
type: custom:mushroom-entity-card
|
||||||
entity: sensor.<home_name>_best_price_next_in_minutes
|
entity: sensor.<home_name>_best_price_next_in_minutes
|
||||||
|
|
@ -44,10 +49,12 @@ name: Next Cheap Window
|
||||||
icon: mdi:clock-fast
|
icon: mdi:clock-fast
|
||||||
```
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
### Display Period Progress Bar
|
### Display Period Progress Bar
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary>Show YAML: Bar card for period progress</summary>
|
<summary>Show YAML: Display Period Progress Bar</summary>
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
type: custom:bar-card
|
type: custom:bar-card
|
||||||
|
|
@ -72,7 +79,7 @@ severity:
|
||||||
### Notify When Period Is Almost Over
|
### Notify When Period Is Almost Over
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary>Show YAML: Automation — notify when best price period is ending</summary>
|
<summary>Show YAML: Period Ending Notification</summary>
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
automation:
|
automation:
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
# Trend Sensors
|
# 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:
|
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:**
|
**Example automation:**
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Show YAML: Example automation</summary>
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
trigger:
|
trigger:
|
||||||
- platform: numeric_state
|
- platform: numeric_state
|
||||||
|
|
@ -201,6 +206,8 @@ action:
|
||||||
message: "Price trend is about to change direction!"
|
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".
|
**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:
|
For most appliances (dishwasher, washing machine, dryer), a single outlook sensor is enough:
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Show YAML: Basic Automation Pattern</summary>
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
# Example: Start dishwasher when prices are favorable
|
# Example: Start dishwasher when prices are favorable
|
||||||
trigger:
|
trigger:
|
||||||
|
|
@ -248,6 +258,8 @@ action:
|
||||||
entity_id: switch.dishwasher
|
entity_id: switch.dishwasher
|
||||||
```
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
### Combining Multiple Windows
|
### Combining Multiple Windows
|
||||||
|
|
||||||
When short-term and long-term trends disagree, you get richer insight:
|
When short-term and long-term trends disagree, you get richer insight:
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
# Volatility Sensors
|
# 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.
|
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):**
|
**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.
|
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
|
```yaml
|
||||||
automation:
|
automation:
|
||||||
- alias: "Enable battery optimization only on volatile days"
|
- alias: "Enable battery optimization only on volatile days"
|
||||||
|
|
@ -71,6 +76,8 @@ automation:
|
||||||
entity_id: input_boolean.battery_optimization_enabled
|
entity_id: input_boolean.battery_optimization_enabled
|
||||||
```
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Avoid Hard-Coding Numeric Thresholds
|
### 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):**
|
**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.
|
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
|
```yaml
|
||||||
automation:
|
automation:
|
||||||
- alias: "Brittle - Enable battery optimization"
|
- alias: "Brittle - Enable battery optimization"
|
||||||
|
|
@ -97,4 +107,6 @@ automation:
|
||||||
entity_id: input_boolean.battery_optimization_enabled
|
entity_id: input_boolean.battery_optimization_enabled
|
||||||
```
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
By following the "Good Example", your automations become simpler, more readable, and much easier to maintain.
|
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`:
|
Add this to your `configuration.yaml`:
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Show YAML: configuration.yaml Logging</summary>
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
logger:
|
logger:
|
||||||
default: warning
|
default: warning
|
||||||
|
|
@ -80,12 +83,17 @@ logger:
|
||||||
custom_components.tibber_prices: debug
|
custom_components.tibber_prices: debug
|
||||||
```
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
Restart Home Assistant for the change to take effect.
|
Restart Home Assistant for the change to take effect.
|
||||||
|
|
||||||
### Targeted Logging
|
### Targeted Logging
|
||||||
|
|
||||||
For specific subsystems, you can enable logging selectively:
|
For specific subsystems, you can enable logging selectively:
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Show YAML: Targeted Subsystem Logging</summary>
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
logger:
|
logger:
|
||||||
default: warning
|
default: warning
|
||||||
|
|
@ -103,6 +111,8 @@ logger:
|
||||||
custom_components.tibber_prices.sensor: debug
|
custom_components.tibber_prices.sensor: debug
|
||||||
```
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
### Temporary Debug Logging (No Restart)
|
### Temporary Debug Logging (No Restart)
|
||||||
|
|
||||||
You can also enable debug logging temporarily from the HA UI:
|
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**
|
1. Go to **Developer Tools → Services**
|
||||||
2. Call service: `logger.set_level`
|
2. Call service: `logger.set_level`
|
||||||
3. Data:
|
3. Data:
|
||||||
|
<details>
|
||||||
|
<summary>Show YAML example (temporary logger.set_level payload)</summary>
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
custom_components.tibber_prices: debug
|
custom_components.tibber_prices: debug
|
||||||
```
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
This resets when HA restarts.
|
This resets when HA restarts.
|
||||||
|
|
||||||
### Downloading Diagnostics
|
### Downloading Diagnostics
|
||||||
|
|
|
||||||
|
|
@ -71,11 +71,19 @@ const sidebars: SidebarsConfig = {
|
||||||
collapsible: true,
|
collapsible: true,
|
||||||
collapsed: false,
|
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',
|
type: 'category',
|
||||||
label: '📖 Reference',
|
label: '📖 Reference',
|
||||||
link: { type: 'doc', id: 'sensor-reference' },
|
link: { type: 'doc', id: 'sensor-reference' },
|
||||||
items: ['sensor-reference', 'actions'],
|
items: ['sensor-reference'],
|
||||||
collapsible: true,
|
collapsible: true,
|
||||||
collapsed: false,
|
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