Compare commits

..

No commits in common. "4ddd19b1324e184a1f9f3d55d049007291cc4c65" and "8aa57697843db97ae3af0cc1ffb2972d349c5019" have entirely different histories.

74 changed files with 7091 additions and 16505 deletions

View file

@ -77,14 +77,6 @@
"python.testing.pytestArgs": [
"--no-cov"
],
"[json]": {
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.tabSize": 2
},
"[jsonc]": {
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.tabSize": 2
},
"[python]": {
"editor.defaultFormatter": "charliermarsh.ruff",
"editor.formatOnSave": true,

View file

@ -13,8 +13,5 @@ indent_size = 4
# Python style aligns with Black
indent_size = 4
[*.json]
indent_size = 2
[*.md]
trim_trailing_whitespace = false

View file

@ -257,52 +257,6 @@ def add_detail_attributes(attributes: dict, current_period: dict) -> None:
attributes["periods_remaining"] = current_period["periods_remaining"]
def add_period_count_attributes(
attributes: dict,
period_summaries: list[dict],
time: TibberPricesTimeService,
) -> None:
"""
Add per-day period count attributes (priority 5.5).
Counts how many periods fall on today and tomorrow so automations can check
things like "only charge if there are at least 2 cheap periods today".
Args:
attributes: Target dict to add attributes to
period_summaries: All period summaries (already filtered to today+tomorrow)
time: TibberPricesTimeService instance for date comparison
"""
now = time.now()
today = time.get_local_date()
tomorrow = time.get_local_date(offset_days=1)
count_today = 0
count_tomorrow = 0
for period in period_summaries:
start = period.get("start")
if start is None:
continue
if hasattr(start, "date"):
period_date = start.date()
else:
from datetime import datetime # noqa: PLC0415
period_date = datetime.fromisoformat(str(start)).date()
if period_date == today:
count_today += 1
elif period_date == tomorrow:
count_tomorrow += 1
_ = now # used for clarity only
if count_today > 0 or count_tomorrow > 0:
attributes["period_count_today"] = count_today
attributes["period_count_tomorrow"] = count_tomorrow
def add_relaxation_attributes(attributes: dict, current_period: dict) -> None:
"""
Add relaxation information attributes (priority 6).
@ -458,9 +412,6 @@ def build_final_attributes_simple(
# 5. Detail information
add_detail_attributes(attributes, current_period)
# 5.5 Per-day period counts (how many cheap/peak periods per day)
add_period_count_attributes(attributes, period_summaries, time)
# 6. Relaxation information (only if current period was relaxed)
add_relaxation_attributes(attributes, current_period)
@ -478,7 +429,6 @@ def build_final_attributes_simple(
result: dict = {
"timestamp": timestamp,
}
add_period_count_attributes(result, period_summaries, time)
if period_metadata:
add_calculation_summary_attributes(result, period_metadata)
result["periods"] = _convert_periods_to_display_units(period_summaries, factor)

View file

@ -12,10 +12,7 @@ import voluptuous as vol
from custom_components.tibber_prices.const import (
BEST_PRICE_MAX_LEVEL_OPTIONS,
CONF_AVERAGE_SENSOR_DISPLAY,
CONF_BEST_PRICE_EXTEND_TO_VERY_CHEAP,
CONF_BEST_PRICE_FLEX,
CONF_BEST_PRICE_GEOMETRIC_FLEX,
CONF_BEST_PRICE_MAX_EXTENSION_INTERVALS,
CONF_BEST_PRICE_MAX_LEVEL,
CONF_BEST_PRICE_MAX_LEVEL_GAP_COUNT,
CONF_BEST_PRICE_MIN_DISTANCE_FROM_AVG,
@ -26,10 +23,7 @@ from custom_components.tibber_prices.const import (
CONF_EXTENDED_DESCRIPTIONS,
CONF_MIN_PERIODS_BEST,
CONF_MIN_PERIODS_PEAK,
CONF_PEAK_PRICE_EXTEND_TO_VERY_EXPENSIVE,
CONF_PEAK_PRICE_FLEX,
CONF_PEAK_PRICE_GEOMETRIC_FLEX,
CONF_PEAK_PRICE_MAX_EXTENSION_INTERVALS,
CONF_PEAK_PRICE_MAX_LEVEL_GAP_COUNT,
CONF_PEAK_PRICE_MIN_DISTANCE_FROM_AVG,
CONF_PEAK_PRICE_MIN_LEVEL,
@ -55,10 +49,7 @@ from custom_components.tibber_prices.const import (
CONF_VOLATILITY_THRESHOLD_MODERATE,
CONF_VOLATILITY_THRESHOLD_VERY_HIGH,
DEFAULT_AVERAGE_SENSOR_DISPLAY,
DEFAULT_BEST_PRICE_EXTEND_TO_VERY_CHEAP,
DEFAULT_BEST_PRICE_FLEX,
DEFAULT_BEST_PRICE_GEOMETRIC_FLEX,
DEFAULT_BEST_PRICE_MAX_EXTENSION_INTERVALS,
DEFAULT_BEST_PRICE_MAX_LEVEL,
DEFAULT_BEST_PRICE_MAX_LEVEL_GAP_COUNT,
DEFAULT_BEST_PRICE_MIN_DISTANCE_FROM_AVG,
@ -68,10 +59,7 @@ from custom_components.tibber_prices.const import (
DEFAULT_EXTENDED_DESCRIPTIONS,
DEFAULT_MIN_PERIODS_BEST,
DEFAULT_MIN_PERIODS_PEAK,
DEFAULT_PEAK_PRICE_EXTEND_TO_VERY_EXPENSIVE,
DEFAULT_PEAK_PRICE_FLEX,
DEFAULT_PEAK_PRICE_GEOMETRIC_FLEX,
DEFAULT_PEAK_PRICE_MAX_EXTENSION_INTERVALS,
DEFAULT_PEAK_PRICE_MAX_LEVEL_GAP_COUNT,
DEFAULT_PEAK_PRICE_MIN_DISTANCE_FROM_AVG,
DEFAULT_PEAK_PRICE_MIN_LEVEL,
@ -98,9 +86,7 @@ from custom_components.tibber_prices.const import (
DEFAULT_VOLATILITY_THRESHOLD_VERY_HIGH,
DISPLAY_MODE_BASE,
DISPLAY_MODE_SUBUNIT,
MAX_EXTENSION_INTERVALS,
MAX_GAP_COUNT,
MAX_GEOMETRIC_FLEX,
MAX_MIN_PERIOD_LENGTH,
MAX_MIN_PERIODS,
MAX_PRICE_LEVEL_GAP_TOLERANCE,
@ -632,7 +618,6 @@ def get_best_price_schema(
period_settings = options.get("period_settings", {})
flexibility_settings = options.get("flexibility_settings", {})
relaxation_settings = options.get("relaxation_and_target_periods", {})
extension_settings = options.get("extension_settings", {})
# Get current values for override display
min_period_length = int(
@ -648,13 +633,6 @@ def get_best_price_schema(
enable_min_periods = relaxation_settings.get(CONF_ENABLE_MIN_PERIODS_BEST, DEFAULT_ENABLE_MIN_PERIODS_BEST)
min_periods = int(relaxation_settings.get(CONF_MIN_PERIODS_BEST, DEFAULT_MIN_PERIODS_BEST))
relaxation_attempts = int(relaxation_settings.get(CONF_RELAXATION_ATTEMPTS_BEST, DEFAULT_RELAXATION_ATTEMPTS_BEST))
extend_to_very_cheap = bool(
extension_settings.get(CONF_BEST_PRICE_EXTEND_TO_VERY_CHEAP, DEFAULT_BEST_PRICE_EXTEND_TO_VERY_CHEAP)
)
max_extension_intervals_best = int(
extension_settings.get(CONF_BEST_PRICE_MAX_EXTENSION_INTERVALS, DEFAULT_BEST_PRICE_MAX_EXTENSION_INTERVALS)
)
geometric_flex_best = int(extension_settings.get(CONF_BEST_PRICE_GEOMETRIC_FLEX, DEFAULT_BEST_PRICE_GEOMETRIC_FLEX))
# Build section schemas with optional override warnings
period_warning = get_section_override_warning("best_price", "period_settings", overrides, translations) or {}
@ -776,40 +754,6 @@ def get_best_price_schema(
vol.Schema(relaxation_fields),
{"collapsed": True},
),
vol.Required("extension_settings"): section(
vol.Schema(
{
vol.Optional(
CONF_BEST_PRICE_EXTEND_TO_VERY_CHEAP,
default=extend_to_very_cheap,
): BooleanSelector(selector.BooleanSelectorConfig()),
vol.Optional(
CONF_BEST_PRICE_MAX_EXTENSION_INTERVALS,
default=max_extension_intervals_best,
): NumberSelector(
NumberSelectorConfig(
min=1,
max=MAX_EXTENSION_INTERVALS,
step=1,
mode=NumberSelectorMode.SLIDER,
)
),
vol.Optional(
CONF_BEST_PRICE_GEOMETRIC_FLEX,
default=geometric_flex_best,
): NumberSelector(
NumberSelectorConfig(
min=0,
max=MAX_GEOMETRIC_FLEX,
step=1,
unit_of_measurement="%",
mode=NumberSelectorMode.SLIDER,
)
),
}
),
{"collapsed": True},
),
}
)
@ -835,7 +779,6 @@ def get_peak_price_schema(
period_settings = options.get("period_settings", {})
flexibility_settings = options.get("flexibility_settings", {})
relaxation_settings = options.get("relaxation_and_target_periods", {})
extension_settings = options.get("extension_settings", {})
# Get current values for override display
min_period_length = int(
@ -851,13 +794,6 @@ def get_peak_price_schema(
enable_min_periods = relaxation_settings.get(CONF_ENABLE_MIN_PERIODS_PEAK, DEFAULT_ENABLE_MIN_PERIODS_PEAK)
min_periods = int(relaxation_settings.get(CONF_MIN_PERIODS_PEAK, DEFAULT_MIN_PERIODS_PEAK))
relaxation_attempts = int(relaxation_settings.get(CONF_RELAXATION_ATTEMPTS_PEAK, DEFAULT_RELAXATION_ATTEMPTS_PEAK))
extend_to_very_expensive = bool(
extension_settings.get(CONF_PEAK_PRICE_EXTEND_TO_VERY_EXPENSIVE, DEFAULT_PEAK_PRICE_EXTEND_TO_VERY_EXPENSIVE)
)
max_extension_intervals_peak = int(
extension_settings.get(CONF_PEAK_PRICE_MAX_EXTENSION_INTERVALS, DEFAULT_PEAK_PRICE_MAX_EXTENSION_INTERVALS)
)
geometric_flex_peak = int(extension_settings.get(CONF_PEAK_PRICE_GEOMETRIC_FLEX, DEFAULT_PEAK_PRICE_GEOMETRIC_FLEX))
# Build section schemas with optional override warnings
period_warning = get_section_override_warning("peak_price", "period_settings", overrides, translations) or {}
@ -979,40 +915,6 @@ def get_peak_price_schema(
vol.Schema(relaxation_fields),
{"collapsed": True},
),
vol.Required("extension_settings"): section(
vol.Schema(
{
vol.Optional(
CONF_PEAK_PRICE_EXTEND_TO_VERY_EXPENSIVE,
default=extend_to_very_expensive,
): BooleanSelector(selector.BooleanSelectorConfig()),
vol.Optional(
CONF_PEAK_PRICE_MAX_EXTENSION_INTERVALS,
default=max_extension_intervals_peak,
): NumberSelector(
NumberSelectorConfig(
min=1,
max=MAX_EXTENSION_INTERVALS,
step=1,
mode=NumberSelectorMode.SLIDER,
)
),
vol.Optional(
CONF_PEAK_PRICE_GEOMETRIC_FLEX,
default=geometric_flex_peak,
): NumberSelector(
NumberSelectorConfig(
min=0,
max=MAX_GEOMETRIC_FLEX,
step=1,
unit_of_measurement="%",
mode=NumberSelectorMode.SLIDER,
)
),
}
),
{"collapsed": True},
),
}
)

View file

@ -68,12 +68,6 @@ CONF_RELAXATION_ATTEMPTS_BEST = "relaxation_attempts_best"
CONF_ENABLE_MIN_PERIODS_PEAK = "enable_min_periods_peak"
CONF_MIN_PERIODS_PEAK = "min_periods_peak"
CONF_RELAXATION_ATTEMPTS_PEAK = "relaxation_attempts_peak"
CONF_BEST_PRICE_EXTEND_TO_VERY_CHEAP = "best_price_extend_to_very_cheap"
CONF_BEST_PRICE_MAX_EXTENSION_INTERVALS = "best_price_max_extension_intervals"
CONF_BEST_PRICE_GEOMETRIC_FLEX = "best_price_geometric_flex"
CONF_PEAK_PRICE_EXTEND_TO_VERY_EXPENSIVE = "peak_price_extend_to_very_expensive"
CONF_PEAK_PRICE_MAX_EXTENSION_INTERVALS = "peak_price_max_extension_intervals"
CONF_PEAK_PRICE_GEOMETRIC_FLEX = "peak_price_geometric_flex"
ATTRIBUTION = "Data provided by Tibber"
@ -137,12 +131,6 @@ DEFAULT_RELAXATION_ATTEMPTS_BEST = 11 # Default: 11 steps allows escalation fro
DEFAULT_ENABLE_MIN_PERIODS_PEAK = True # Default: minimum periods feature enabled for peak price
DEFAULT_MIN_PERIODS_PEAK = 2 # Default: require at least 2 peak price periods (when enabled)
DEFAULT_RELAXATION_ATTEMPTS_PEAK = 11 # Default: 11 steps allows escalation from 20% to 50% (3% increment per step)
DEFAULT_BEST_PRICE_EXTEND_TO_VERY_CHEAP = False # Default: disabled (opt-in feature)
DEFAULT_BEST_PRICE_MAX_EXTENSION_INTERVALS = 4 # Default: up to 4 intervals (1 hour) per side
DEFAULT_BEST_PRICE_GEOMETRIC_FLEX = 0 # Default: 0% (disabled); positive int % (e.g. 10 = 10%)
DEFAULT_PEAK_PRICE_EXTEND_TO_VERY_EXPENSIVE = False # Default: disabled (opt-in feature)
DEFAULT_PEAK_PRICE_MAX_EXTENSION_INTERVALS = 4 # Default: up to 4 intervals (1 hour) per side
DEFAULT_PEAK_PRICE_GEOMETRIC_FLEX = 0 # Default: 0% (disabled); positive int % (e.g. 10 = 10%)
# Validation limits (used in GUI schemas and server-side validation)
# These ensure consistency between frontend and backend validation
@ -151,8 +139,6 @@ MAX_DISTANCE_PERCENTAGE = 50 # Maximum distance from average percentage (GUI sl
MAX_GAP_COUNT = 8 # Maximum gap count for level filtering (GUI slider limit)
MAX_MIN_PERIODS = 10 # Maximum number of minimum periods per day (GUI slider limit)
MAX_RELAXATION_ATTEMPTS = 12 # Maximum relaxation attempts (GUI slider limit)
MAX_EXTENSION_INTERVALS = 12 # Maximum extension intervals per side (GUI slider limit = 3 hours)
MAX_GEOMETRIC_FLEX = 25 # Maximum geometric flex bonus percentage (GUI slider limit)
MIN_PERIOD_LENGTH = 15 # Minimum period length in minutes (1 quarter hour)
MAX_MIN_PERIOD_LENGTH = 180 # Maximum for minimum period length setting (3 hours - realistic for required minimum)
@ -421,15 +407,6 @@ def get_default_options(currency_code: str | None) -> dict[str, Any]:
CONF_MIN_PERIODS_PEAK: DEFAULT_MIN_PERIODS_PEAK,
CONF_RELAXATION_ATTEMPTS_PEAK: DEFAULT_RELAXATION_ATTEMPTS_PEAK,
},
# Nested section: Extension settings (shared by best/peak price)
"extension_settings": {
CONF_BEST_PRICE_EXTEND_TO_VERY_CHEAP: DEFAULT_BEST_PRICE_EXTEND_TO_VERY_CHEAP,
CONF_BEST_PRICE_MAX_EXTENSION_INTERVALS: DEFAULT_BEST_PRICE_MAX_EXTENSION_INTERVALS,
CONF_BEST_PRICE_GEOMETRIC_FLEX: DEFAULT_BEST_PRICE_GEOMETRIC_FLEX,
CONF_PEAK_PRICE_EXTEND_TO_VERY_EXPENSIVE: DEFAULT_PEAK_PRICE_EXTEND_TO_VERY_EXPENSIVE,
CONF_PEAK_PRICE_MAX_EXTENSION_INTERVALS: DEFAULT_PEAK_PRICE_MAX_EXTENSION_INTERVALS,
CONF_PEAK_PRICE_GEOMETRIC_FLEX: DEFAULT_PEAK_PRICE_GEOMETRIC_FLEX,
},
}

View file

@ -788,18 +788,6 @@ class TibberPricesDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
else:
# Check for repair conditions after successful update
await self._check_repair_conditions(result, current_time)
# Fire event when new data was fetched from API (not cached)
if api_called and result and "priceInfo" in result and len(result["priceInfo"]) > 0:
self.hass.bus.async_fire(
"tibber_prices_data_updated",
{
"home_id": self._home_id,
"entry_id": self.config_entry.entry_id,
"interval_count": len(result["priceInfo"]),
},
)
return result
async def _track_rate_limit_error(self, error: Exception) -> None:
@ -875,11 +863,9 @@ class TibberPricesDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
"""Get threshold percentages from config options."""
return self._data_transformer.get_threshold_percentages()
def _calculate_periods_for_price_info(
self, price_info: dict[str, Any], day_patterns: dict[str, Any] | None = None
) -> dict[str, Any]:
def _calculate_periods_for_price_info(self, price_info: dict[str, Any]) -> dict[str, Any]:
"""Calculate periods (best price and peak price) for the given price info."""
return self._period_calculator.calculate_periods_for_price_info(price_info, day_patterns)
return self._period_calculator.calculate_periods_for_price_info(price_info)
def _transform_data(self, raw_data: dict[str, Any]) -> dict[str, Any]:
"""Transform raw data for main entry (aggregated view of all homes)."""

View file

@ -7,7 +7,6 @@ import logging
from typing import TYPE_CHECKING, Any
from custom_components.tibber_prices import const as _const
from custom_components.tibber_prices.coordinator.period_handlers.day_pattern import detect_day_patterns
from custom_components.tibber_prices.utils.price import enrich_price_info_with_differences
if TYPE_CHECKING:
@ -28,7 +27,7 @@ class TibberPricesDataTransformer:
self,
config_entry: ConfigEntry,
log_prefix: str,
calculate_periods_fn: Callable[[dict[str, Any], dict[str, Any] | None], dict[str, Any]],
calculate_periods_fn: Callable[[dict[str, Any]], dict[str, Any]],
time: TibberPricesTimeService,
) -> None:
"""Initialize the data transformer."""
@ -271,18 +270,9 @@ class TibberPricesDataTransformer:
"currency": currency,
}
# Detect day patterns (yesterday / today / tomorrow)
# IMPORTANT: Must be computed BEFORE pricePeriods so geometric flex can use pattern data
transformed_data["dayPatterns"] = detect_day_patterns(
transformed_data["priceInfo"],
time=self.time,
)
# Calculate periods (best price and peak price)
if "priceInfo" in transformed_data:
transformed_data["pricePeriods"] = self._calculate_periods_fn(
transformed_data["priceInfo"], transformed_data.get("dayPatterns")
)
transformed_data["pricePeriods"] = self._calculate_periods_fn(transformed_data["priceInfo"])
# Cache the transformed data
self._cached_transformed_data = transformed_data

View file

@ -19,37 +19,20 @@ from __future__ import annotations
# Re-export main API functions
from .core import calculate_periods
# Re-export day pattern detection
from .day_pattern import detect_day_patterns
# Re-export outlier filtering
from .outlier_filtering import filter_price_outliers
# Re-export relaxation
from .relaxation import calculate_periods_with_relaxation
# Re-export shape extension
from .shape_extension import extend_periods_for_shape
# Re-export constants and types
from .types import (
ALL_DAY_PATTERNS,
DAY_PATTERN_DOUBLE_PEAK,
DAY_PATTERN_DOUBLE_VALLEY,
DAY_PATTERN_FALLING,
DAY_PATTERN_FLAT,
DAY_PATTERN_MIXED,
DAY_PATTERN_PEAK,
DAY_PATTERN_RISING,
DAY_PATTERN_VALLEY,
INDENT_L0,
INDENT_L1,
INDENT_L2,
INDENT_L3,
INDENT_L4,
INDENT_L5,
DayPatternDict,
SegmentDict,
TibberPricesIntervalCriteria,
TibberPricesPeriodConfig,
TibberPricesPeriodData,
@ -58,23 +41,12 @@ from .types import (
)
__all__ = [
"ALL_DAY_PATTERNS",
"DAY_PATTERN_DOUBLE_PEAK",
"DAY_PATTERN_DOUBLE_VALLEY",
"DAY_PATTERN_FALLING",
"DAY_PATTERN_FLAT",
"DAY_PATTERN_MIXED",
"DAY_PATTERN_PEAK",
"DAY_PATTERN_RISING",
"DAY_PATTERN_VALLEY",
"INDENT_L0",
"INDENT_L1",
"INDENT_L2",
"INDENT_L3",
"INDENT_L4",
"INDENT_L5",
"DayPatternDict",
"SegmentDict",
"TibberPricesIntervalCriteria",
"TibberPricesPeriodConfig",
"TibberPricesPeriodData",
@ -82,7 +54,5 @@ __all__ = [
"TibberPricesThresholdConfig",
"calculate_periods",
"calculate_periods_with_relaxation",
"detect_day_patterns",
"extend_periods_for_shape",
"filter_price_outliers",
]

View file

@ -25,7 +25,6 @@ from .period_building import (
from .period_statistics import (
extract_period_summaries,
)
from .shape_extension import extend_periods_for_shape
from .types import TibberPricesThresholdConfig
# Flex limits to prevent degenerate behavior (see docs/development/period-calculation-theory.md)
@ -38,7 +37,6 @@ def calculate_periods(
*,
config: TibberPricesPeriodConfig,
time: TibberPricesTimeService,
day_patterns_by_date: dict | None = None,
) -> dict[str, Any]:
"""
Calculate price periods (best or peak) from price data.
@ -60,7 +58,6 @@ def calculate_periods(
config: Period configuration containing reverse_sort, flex, min_distance_from_avg,
min_period_length, threshold_low, and threshold_high.
time: TibberPricesTimeService instance (required).
day_patterns_by_date: Optional dict mapping date day pattern dict for geometric flex bonus.
Returns:
Dict with:
@ -158,8 +155,6 @@ def calculate_periods(
"intervals_by_day": intervals_by_day, # Needed for day volatility calculation
"flex": flex,
"min_distance_from_avg": min_distance_from_avg,
"geometric_extra_flex": config.geometric_extra_flex, # Extra flex for geometric zone
"day_patterns_by_date": day_patterns_by_date, # Pattern data keyed by date (may be None)
}
raw_periods = build_periods(
all_prices_smoothed, # Use smoothed prices for period formation
@ -214,20 +209,6 @@ def calculate_periods(
time=time,
)
# Step 7.5: Extend periods into adjacent VERY_CHEAP / VERY_EXPENSIVE intervals
# This is an opt-in feature (disabled by default) that adds contiguous
# extreme-level intervals on each side of an already-found period.
if config.extend_to_extreme and config.max_extension_intervals > 0:
period_summaries = extend_periods_for_shape(
period_summaries,
all_prices_sorted,
price_context,
reverse_sort=reverse_sort,
max_extension_intervals=config.max_extension_intervals,
thresholds=thresholds,
time=time,
)
# Step 8: Cross-day extension for late-night periods
# If a best-price period ends near midnight and tomorrow has continued low prices,
# extend the period across midnight to give users the full cheap window

View file

@ -1,577 +0,0 @@
"""
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

View file

@ -11,11 +11,9 @@ See docs/development/period-calculation-theory.md for detailed explanation.
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from datetime import datetime
from .types import TibberPricesIntervalCriteria
from custom_components.tibber_prices.const import PRICE_LEVEL_MAPPING
@ -243,54 +241,3 @@ def check_interval_criteria(
meets_min_distance = price <= min_distance_threshold
return in_flex, meets_min_distance
def compute_geometric_flex_bonus(
interval_time: datetime,
day_pattern: dict[str, Any] | None,
*,
extra_flex: float,
reverse_sort: bool,
) -> float:
"""
Return extra flex if interval falls within the valley/peak geometric zone.
For best price (reverse_sort=False): widens flex inside the VALLEY zone
defined by [valley_start, valley_end] knee points.
For peak price (reverse_sort=True): widens flex inside the PEAK zone
defined by [peak_start, peak_end] knee points.
Args:
interval_time: Timezone-aware datetime of the interval's start.
day_pattern: DayPatternDict for the interval's calendar day, or None.
extra_flex: Additional flex to add (decimal, e.g. 0.10 for 10%).
reverse_sort: True for peak price, False for best price.
Returns:
``extra_flex`` if the interval is inside the geometric zone, else ``0.0``.
"""
if not day_pattern or extra_flex <= 0:
return 0.0
pattern = day_pattern.get("pattern", "")
if reverse_sort:
# Peak price: expand inside PEAK (Λ-shape) zone
if pattern != "peak":
return 0.0
zone_start = day_pattern.get("peak_start")
zone_end = day_pattern.get("peak_end")
else:
# Best price: expand inside VALLEY (V/U-shape) zone
if pattern != "valley":
return 0.0
zone_start = day_pattern.get("valley_start")
zone_end = day_pattern.get("valley_end")
if zone_start is None or zone_end is None:
return 0.0
if zone_start <= interval_time <= zone_end:
return extra_flex
return 0.0

View file

@ -14,7 +14,6 @@ if TYPE_CHECKING:
from .level_filtering import (
apply_level_filter,
check_interval_criteria,
compute_geometric_flex_bonus,
)
from .types import TibberPricesIntervalCriteria
@ -84,8 +83,6 @@ def build_periods( # noqa: PLR0913, PLR0915, PLR0912 - Complex period building
avg_prices = price_context["avg_prices"]
flex = price_context["flex"]
min_distance_from_avg = price_context["min_distance_from_avg"]
geometric_extra_flex: float = float(price_context.get("geometric_extra_flex", 0.0))
day_patterns_by_date: dict[date, dict[str, Any]] | None = price_context.get("day_patterns_by_date")
# Calculate level_order if level_filter is active
level_order = None
@ -150,20 +147,7 @@ def build_periods( # noqa: PLR0913, PLR0915, PLR0912 - Complex period building
# Check flex and minimum distance criteria (using smoothed price and interval's own day reference)
criteria = criteria_by_day[ref_date]
# 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)
in_flex, meets_min_distance = check_interval_criteria(price_for_criteria, criteria)
# Track why intervals are filtered
if not in_flex:
@ -175,7 +159,7 @@ def build_periods( # noqa: PLR0913, PLR0915, PLR0912 - Complex period building
smoothing_was_impactful = False
if price_data.get("_smoothed", False):
# Check if original price would have passed the same criteria
in_flex_original, meets_min_distance_original = check_interval_criteria(price_original, effective_criteria)
in_flex_original, meets_min_distance_original = check_interval_criteria(price_original, criteria)
# Smoothing was impactful if original would have failed but smoothed passed
smoothing_was_impactful = (in_flex and meets_min_distance) and not (
in_flex_original and meets_min_distance_original
@ -200,7 +184,6 @@ def build_periods( # noqa: PLR0913, PLR0915, PLR0912 - Complex period building
# Only True if smoothing changed whether the interval qualified for period inclusion
"smoothing_was_impactful": smoothing_was_impactful,
"is_level_gap": is_level_gap, # Track if kept due to level gap tolerance
"geometric_bonus_applied": geo_bonus > 0, # True if interval is in geometric zone
}
)
elif current_period:

View file

@ -220,17 +220,6 @@ def build_period_summary_dict(
return summary
def _add_interval_flag_counts(summary: dict, period: list[dict]) -> None:
"""Add optional interval flag counts to period summary."""
if (count := sum(1 for i in period if i.get("smoothing_was_impactful", False))) > 0:
summary["period_interval_smoothed_count"] = count
if (count := sum(1 for i in period if i.get("is_level_gap", False))) > 0:
summary["period_interval_level_gap_count"] = count
if (count := sum(1 for i in period if i.get("geometric_bonus_applied", False))) > 0:
summary["geometric_extension_active"] = True
summary["geometric_extension_intervals"] = count
def extract_period_summaries(
periods: list[list[dict]],
all_prices: list[dict],
@ -339,6 +328,12 @@ def extract_period_summaries(
).lower()
rating_difference_pct = calculate_aggregated_rating_difference(period_price_data)
# Count how many intervals in this period benefited from smoothing (i.e., would have been excluded)
smoothed_impactful_count = sum(1 for interval in period if interval.get("smoothing_was_impactful", False))
# Count how many intervals were kept due to level filter gap tolerance
level_gap_count = sum(1 for interval in period if interval.get("is_level_gap", False))
# Build period data and statistics objects
period_data = TibberPricesPeriodData(
start_time=start_time,
@ -368,8 +363,13 @@ def extract_period_summaries(
period_data, stats, reverse_sort=thresholds.reverse_sort, price_context=price_context
)
# Add optional interval flag counts (smoothing, level gaps, geometric extension)
_add_interval_flag_counts(summary, period)
# Add smoothing information if any intervals benefited from smoothing
if smoothed_impactful_count > 0:
summary["period_interval_smoothed_count"] = smoothed_impactful_count
# Add level gap tolerance information if any intervals were kept as gaps
if level_gap_count > 0:
summary["period_interval_level_gap_count"] = level_gap_count
summaries.append(summary)

View file

@ -526,7 +526,6 @@ def calculate_periods_with_relaxation( # noqa: PLR0912, PLR0913, PLR0915 - Per-
should_show_callback: Callable[[str | None], bool],
time: TibberPricesTimeService,
config_entry: Any, # ConfigEntry type
day_patterns_by_date: dict | None = None,
) -> dict[str, Any]:
"""
Calculate periods with optional per-day filter relaxation.
@ -553,8 +552,6 @@ def calculate_periods_with_relaxation( # noqa: PLR0912, PLR0913, PLR0915 - Per-
to use original configured filter values.
time: TibberPricesTimeService instance (required).
config_entry: Config entry to get display unit configuration.
day_patterns_by_date: Optional dict mapping date day pattern dict. Used for
geometric flex bonus in period detection. Passed through to calculate_periods().
Returns:
Dict with same format as calculate_periods() output:
@ -712,7 +709,7 @@ def calculate_periods_with_relaxation( # noqa: PLR0912, PLR0913, PLR0915 - Per-
# === BASELINE CALCULATION (process ALL prices together, including yesterday) ===
# Periods that ended before yesterday will be filtered out later by filter_periods_by_end_date()
# This keeps yesterday/today/tomorrow periods in the cache
baseline_result = calculate_periods(all_prices, config=config, time=time, day_patterns_by_date=day_patterns_by_date)
baseline_result = calculate_periods(all_prices, config=config, time=time)
all_periods = baseline_result["periods"]
# Count periods per day for min_periods check
@ -768,7 +765,6 @@ def calculate_periods_with_relaxation( # noqa: PLR0912, PLR0913, PLR0915 - Per-
baseline_periods=all_periods,
time=time,
config_entry=config_entry,
day_patterns_by_date=day_patterns_by_date,
)
all_periods = relaxed_result["periods"]
@ -869,7 +865,6 @@ def relax_all_prices( # noqa: PLR0913 - Comprehensive filter relaxation require
*,
time: TibberPricesTimeService,
config_entry: Any, # ConfigEntry type
day_patterns_by_date: dict | None = None,
) -> tuple[dict[str, Any], dict[str, Any]]:
"""
Relax filters for all prices until min_periods per day is reached.
@ -888,8 +883,6 @@ def relax_all_prices( # noqa: PLR0913 - Comprehensive filter relaxation require
baseline_periods: Baseline periods (before relaxation).
time: TibberPricesTimeService instance.
config_entry: Config entry to get display unit configuration.
day_patterns_by_date: Optional dict mapping date day pattern dict. Used for
geometric flex bonus in period detection. Passed through to calculate_periods().
Returns:
Tuple of (result_dict, metadata_dict)
@ -954,9 +947,7 @@ def relax_all_prices( # noqa: PLR0913 - Comprehensive filter relaxation require
)
# Process ALL prices together (allows midnight crossing)
result = calculate_periods(
all_prices, config=relaxed_config, time=time, day_patterns_by_date=day_patterns_by_date
)
result = calculate_periods(all_prices, config=relaxed_config, time=time)
new_periods = result["periods"]
_LOGGER_DETAILS.debug(

View file

@ -1,258 +0,0 @@
"""
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

View file

@ -2,7 +2,7 @@
from __future__ import annotations
from typing import TYPE_CHECKING, NamedTuple, TypedDict
from typing import TYPE_CHECKING, NamedTuple
if TYPE_CHECKING:
from datetime import datetime
@ -56,9 +56,6 @@ class TibberPricesPeriodConfig(NamedTuple):
threshold_volatility_very_high: float = DEFAULT_VOLATILITY_THRESHOLD_VERY_HIGH
level_filter: str | None = None # "any", "cheap", "expensive", etc. or None
gap_count: int = 0 # Number of allowed consecutive deviating intervals
extend_to_extreme: bool = False # Extend periods into adjacent VERY_CHEAP/VERY_EXPENSIVE intervals
max_extension_intervals: int = 0 # Max intervals this extension may add per side (0 = disabled)
geometric_extra_flex: float = 0.0 # Extra flex (decimal) for intervals inside the valley/peak zone (0.0 = disabled)
class TibberPricesPeriodData(NamedTuple):
@ -107,60 +104,3 @@ class TibberPricesIntervalCriteria(NamedTuple):
flex: float
min_distance_from_avg: float
reverse_sort: bool
# ─── Day pattern constants ─────────────────────────────────────────────────────
DAY_PATTERN_VALLEY = "valley" # Single price minimum (U/V-shape)
DAY_PATTERN_PEAK = "peak" # Single price maximum (Λ-shape)
DAY_PATTERN_DOUBLE_VALLEY = "double_valley" # Two minima, W-shape
DAY_PATTERN_DOUBLE_PEAK = "double_peak" # Two peaks, M-shape
DAY_PATTERN_FLAT = "flat" # No significant variation
DAY_PATTERN_RISING = "rising" # Persistently rising throughout the day
DAY_PATTERN_FALLING = "falling" # Persistently falling throughout the day
DAY_PATTERN_MIXED = "mixed" # Multiple extrema with no clear pattern
# Ordered list used to populate SensorDeviceClass.ENUM options=
ALL_DAY_PATTERNS: list[str] = [
DAY_PATTERN_VALLEY,
DAY_PATTERN_PEAK,
DAY_PATTERN_DOUBLE_VALLEY,
DAY_PATTERN_DOUBLE_PEAK,
DAY_PATTERN_FLAT,
DAY_PATTERN_RISING,
DAY_PATTERN_FALLING,
DAY_PATTERN_MIXED,
]
# Segment type constants
DAY_SEGMENT_RISING = "rising"
DAY_SEGMENT_FALLING = "falling"
DAY_SEGMENT_FLAT = "flat"
# ─── Day pattern TypedDicts ────────────────────────────────────────────────────
class SegmentDict(TypedDict):
"""One monotone price segment within a calendar day."""
type: str # "rising" | "falling" | "flat"
start: str | None # ISO datetime of first interval in segment
end: str | None # ISO datetime of last interval in segment
price_min: float # Minimum price in segment
price_max: float # Maximum price in segment
price_mean: float # Mean price in segment
class DayPatternDict(TypedDict):
"""Detected price pattern for one calendar day."""
pattern: str # One of the DAY_PATTERN_* constants
confidence: float # 0.0 - 1.0
day_cv_percent: float # Coefficient of variation for the day (%)
segments: list[SegmentDict] # Monotone segments
extreme_time: str | None # ISO datetime of primary extremum (valley/peak)
valley_start: str | None # ISO datetime of left knee (VALLEY pattern only)
valley_end: str | None # ISO datetime of right knee (VALLEY pattern only)
peak_start: str | None # ISO datetime of left knee (PEAK pattern only)
peak_end: str | None # ISO datetime of right knee (PEAK pattern only)

View file

@ -8,7 +8,6 @@ gap tolerance, and coordination of the period_handlers calculation functions.
from __future__ import annotations
import logging
from datetime import date, timedelta
from typing import TYPE_CHECKING, Any
from custom_components.tibber_prices import const as _const
@ -226,60 +225,6 @@ class TibberPricesPeriodCalculator:
"min_period_length": int(min_period_length),
}
# Extension settings (stored in 'extension_settings' nested section)
if reverse_sort:
extend_to_extreme = bool(
self._get_option(
_const.CONF_PEAK_PRICE_EXTEND_TO_VERY_EXPENSIVE,
"extension_settings",
_const.DEFAULT_PEAK_PRICE_EXTEND_TO_VERY_EXPENSIVE,
)
)
max_extension_intervals = int(
self._get_option(
_const.CONF_PEAK_PRICE_MAX_EXTENSION_INTERVALS,
"extension_settings",
_const.DEFAULT_PEAK_PRICE_MAX_EXTENSION_INTERVALS,
)
)
else:
extend_to_extreme = bool(
self._get_option(
_const.CONF_BEST_PRICE_EXTEND_TO_VERY_CHEAP,
"extension_settings",
_const.DEFAULT_BEST_PRICE_EXTEND_TO_VERY_CHEAP,
)
)
max_extension_intervals = int(
self._get_option(
_const.CONF_BEST_PRICE_MAX_EXTENSION_INTERVALS,
"extension_settings",
_const.DEFAULT_BEST_PRICE_MAX_EXTENSION_INTERVALS,
)
)
config["extend_to_extreme"] = extend_to_extreme
config["max_extension_intervals"] = max_extension_intervals
# Geometric flex bonus (intervals inside valley/peak zone get extra flex)
if reverse_sort:
geometric_flex_pct = int(
self._get_option(
_const.CONF_PEAK_PRICE_GEOMETRIC_FLEX,
"extension_settings",
_const.DEFAULT_PEAK_PRICE_GEOMETRIC_FLEX,
)
)
else:
geometric_flex_pct = int(
self._get_option(
_const.CONF_BEST_PRICE_GEOMETRIC_FLEX,
"extension_settings",
_const.DEFAULT_BEST_PRICE_GEOMETRIC_FLEX,
)
)
config["geometric_extra_flex"] = geometric_flex_pct / 100
# Cache the result
self._config_cache[cache_key] = config
self._config_cache_valid = True
@ -653,7 +598,6 @@ class TibberPricesPeriodCalculator:
def calculate_periods_for_price_info(
self,
price_info: dict[str, Any],
day_patterns: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""
Calculate periods (best price and peak price) for the given price info.
@ -678,19 +622,6 @@ class TibberPricesPeriodCalculator:
coordinator_data = {"priceInfo": price_info}
all_prices = get_intervals_for_day_offsets(coordinator_data, [-2, -1, 0, 1])
# Convert day_patterns (keyed by "yesterday"/"today"/"tomorrow") to date-keyed dict
# Needed for geometric valley/peak zone flex bonus in period calculation
today_date = self.time.now().date()
day_patterns_by_date: dict[date, dict[str, Any]] | None = (
{
today_date + timedelta(days=ofs): pat
for ofs, lbl in ((-1, "yesterday"), (0, "today"), (1, "tomorrow"))
if (pat := day_patterns.get(lbl)) is not None
}
if day_patterns
else None
)
# Get rating thresholds from config (flat in options, not in sections)
# CRITICAL: Price rating thresholds are stored FLAT in options (no sections)
threshold_low = self.config_entry.options.get(
@ -771,9 +702,6 @@ class TibberPricesPeriodCalculator:
threshold_volatility_very_high=threshold_volatility_very_high,
level_filter=max_level_best,
gap_count=gap_count_best,
extend_to_extreme=best_config["extend_to_extreme"],
max_extension_intervals=best_config["max_extension_intervals"],
geometric_extra_flex=best_config["geometric_extra_flex"],
)
best_periods = calculate_periods_with_relaxation(
all_prices,
@ -788,7 +716,6 @@ class TibberPricesPeriodCalculator:
),
time=self.time,
config_entry=self.config_entry,
day_patterns_by_date=day_patterns_by_date,
)
else:
best_periods = {
@ -856,9 +783,6 @@ class TibberPricesPeriodCalculator:
threshold_volatility_very_high=threshold_volatility_very_high,
level_filter=min_level_peak,
gap_count=gap_count_peak,
extend_to_extreme=peak_config["extend_to_extreme"],
max_extension_intervals=peak_config["max_extension_intervals"],
geometric_extra_flex=peak_config["geometric_extra_flex"],
)
peak_periods = calculate_periods_with_relaxation(
all_prices,
@ -873,7 +797,6 @@ class TibberPricesPeriodCalculator:
),
time=self.time,
config_entry=self.config_entry,
day_patterns_by_date=day_patterns_by_date,
)
else:
peak_periods = {

View file

@ -486,21 +486,6 @@
"long_description": "Zeigt, ob dein Tibber-Abonnement derzeit aktiv ist, beendet wurde oder auf Aktivierung wartet. Ein Status 'Aktiv' bedeutet, dass du aktiv Strom über Tibber beziehst.",
"usage_tips": "Nutze dies zur Überwachung deines Abonnementstatus. Richte Benachrichtigungen ein, wenn sich der Status von 'Aktiv' ändert, um einen unterbrechungsfreien Service sicherzustellen."
},
"day_pattern_yesterday": {
"description": "Erkanntes Preismuster der gestrigen Strompreise",
"long_description": "Klassifiziert gestern in ein Preismuster: Tal (günstig in der Mitte), Gipfel (teuer in der Mitte), Doppeltal (zwei günstige Perioden), Doppelgipfel (zwei teure Perioden), Flach (geringe Variation), Steigend, Fallend oder Gemischt. Die Konfidenz- und CV-Attribute zeigen, wie verlässlich das Muster erkannt wurde.",
"usage_tips": "Nutze das gestrige Muster für Automationen: Ein Tal-Tag wiederholt sich oft am nächsten Tag und deutet darauf hin, dass Verbraucher auf die günstigen Mittagsstunden verschoben werden sollten."
},
"day_pattern_today": {
"description": "Erkanntes Preismuster der heutigen Strompreise",
"long_description": "Klassifiziert heute in ein Preismuster: Tal (günstig mittags), Gipfel (teuer mittags), Doppeltal (W-Form), Doppelgipfel (M-Form), Flach, Steigend, Fallend oder Gemischt. Attribute enthalten Konfidenz (01), Variationskoeffizient, Knickpunktzeiten und Tagessegmente.",
"usage_tips": "Nutze das Tagesmuster, um Verbraucher zu verschieben. Tal-Tag: Spülmaschine, Waschmaschine oder E-Auto-Laden in die günstige Mittagszeit legen. Gipfel-Tag: früh morgens oder spät abends waschen. Die Attribute valley_start und valley_end ermöglichen minutengenaue Automationen."
},
"day_pattern_tomorrow": {
"description": "Erkanntes Preismuster der morgigen Strompreise",
"long_description": "Klassifiziert morgen (sobald Daten verfügbar sind, typisch nach 13 Uhr) in ein Preismuster mit demselben Algorithmus wie heute. Die Attribute valley_start/valley_end oder peak_start/peak_end geben Knickpunktzeiten für das primäre Extremum an.",
"usage_tips": "Richte Abendautomationen ein, die das morgige Muster lesen und Wärmepumpe, Autolader oder Warmwasserbereiter für den nächsten Tag vorkonfigurieren. Kombiniere mit dem tomorrow_data_available Binärsensor."
},
"chart_data_export": {
"description": "Datenexport für Dashboard-Integrationen",
"long_description": "Dieser Sensor ruft den get_chartdata-Service mit deiner konfigurierten YAML-Konfiguration auf und stellt das Ergebnis als Entity-Attribute bereit. Der Status zeigt 'ready' wenn Daten verfügbar sind, 'error' bei Fehlern, oder 'pending' vor dem ersten Aufruf. Perfekt für Dashboard-Integrationen wie ApexCharts, die Preisdaten aus Entity-Attributen lesen.",

View file

@ -486,21 +486,6 @@
"long_description": "Shows whether your Tibber subscription is currently running, has ended, or is pending activation. A status of 'running' means you're actively receiving electricity through Tibber.",
"usage_tips": "Use this to monitor your subscription status. Set up alerts if status changes from 'running' to ensure uninterrupted service."
},
"day_pattern_yesterday": {
"description": "Detected price shape of yesterday's electricity prices",
"long_description": "Classifies yesterday into a price shape: Valley (cheap in the middle), Peak (expensive in the middle), Double Valley (two cheap periods), Double Peak (two expensive periods), Flat (little variation), Rising, Falling, or Mixed. The confidence and CV attributes indicate how reliably the pattern was detected.",
"usage_tips": "Use yesterday's pattern to refine automations: a Valley day often repeats the next day, suggesting you should pre-schedule cheap-hour loads. Pair with the confidence attribute to filter unreliable detections."
},
"day_pattern_today": {
"description": "Detected price shape of today's electricity prices",
"long_description": "Classifies today into a price shape: Valley (cheap in the middle of the day), Peak (expensive in the middle), Double Valley (W-shape, two cheap windows), Double Peak (M-shape, two expensive peaks), Flat (prices barely move), Rising (prices climb through the day), Falling (prices drop through the day), or Mixed. Attributes include confidence (0\u20131), coefficient of variation, knee-point times, and intra-day segments.",
"usage_tips": "Use today's pattern to decide when to shift loads. A Valley day means cheap prices around midday \u2014 ideal for running the dishwasher, washing machine, or charging the EV. A Peak day means expensive midday \u2014 run appliances early morning or late evening. Use valley_start and valley_end attributes to schedule automations precisely."
},
"day_pattern_tomorrow": {
"description": "Detected price shape of tomorrow's electricity prices",
"long_description": "Classifies tomorrow (once data is available, typically after 13:00) into a price shape using the same algorithm as today. The valley_start / valley_end or peak_start / peak_end attributes give knee-point times for the primary extremum so you can pre-schedule loads the evening before.",
"usage_tips": "Set up evening automations that read tomorrow's pattern and pre-configure heat pump schedules, car charging timers, or water heater settings for the following day. Pair with the tomorrow_data_available binary sensor to trigger the automation only when data is ready."
},
"chart_data_export": {
"description": "Data export for dashboard integrations",
"long_description": "This binary sensor calls the get_chartdata service with your configured YAML parameters and exposes the result as entity attributes. The state is 'on' when the service call succeeds and data is available, 'off' when the call fails or no configuration is set. Perfect for dashboard integrations like ApexCharts that need to read price data from entity attributes.",

View file

@ -486,21 +486,6 @@
"long_description": "Viser om Tibber-abonnementet ditt for øyeblikket er aktivt, avsluttet eller venter på aktivering. En status 'Aktiv' betyr at du aktivt mottar strøm gjennom Tibber.",
"usage_tips": "Bruk dette til å overvåke abonnementsstatusen din. Sett opp varsler hvis statusen endres fra 'Aktiv' for å sikre uavbrutt tjeneste."
},
"day_pattern_yesterday": {
"description": "Oppdaget prismønster for gårsdagens strømpriser",
"long_description": "Klassifiserer i går i et prismønster: Dal (billig midt på dagen), Topp (dyrt midt på dagen), Dobbel dal (to billige perioder), Dobbel topp (to dyre perioder), Flat (liten variasjon), Stigende, Fallende eller Blandet. Konfidensen og CV-attributtene viser hvor pålitelig mønsteret ble oppdaget.",
"usage_tips": "Bruk gårsdagens mønster til å forbedre automatikaene dine: et Dalmønster gjentar seg ofte neste dag og antyder at du bør forhåndsplanlegge billige middagstimer."
},
"day_pattern_today": {
"description": "Oppdaget prismønster for dagens strømpriser",
"long_description": "Klassifiserer i dag i et prismønster: Dal (billig midt på dagen), Topp (dyrt midt på dagen), Dobbel dal (W-form), Dobbel topp (M-form), Flat, Stigende, Fallende eller Blandet. Attributter inkluderer konfidensverdi (01), variasjonskoeffisient, knepunktstider og dagsegmenter.",
"usage_tips": "Bruk dagens mønster til å flytte forbruk. Daldag: kjør oppvaskmaskin, vaskemaskin eller lad elbilen rundt billige middagstimer. Toppdag: kjør apparater tidlig morgen eller sent kveld. Bruk valley_start og valley_end for presise automatikaer."
},
"day_pattern_tomorrow": {
"description": "Oppdaget prismønster for morgendagens strømpriser",
"long_description": "Klassifiserer i morgen (når data er tilgjengelig, typisk etter kl. 13) i et prismønster med samme algoritme som i dag. Attributtene valley_start/valley_end eller peak_start/peak_end gir knepunktstider.",
"usage_tips": "Sett opp kveldsautomasjonar som leser morgendagens mønster og forhåndskonfigurerer varmepumpe, billader eller varmtvannsberedere. Kombiner med tomorrow_data_available-binærsensoren."
},
"chart_data_export": {
"description": "Dataeksport for dashboardintegrasjoner",
"long_description": "Denne sensoren kaller get_chartdata-tjenesten med din konfigurerte YAML-konfigurasjon og eksponerer resultatet som entitetsattributter. Status viser 'ready' når data er tilgjengelig, 'error' ved feil, eller 'pending' før første kall. Perfekt for dashboardintegrasjoner som ApexCharts som trenger å lese prisdata fra entitetsattributter.",

View file

@ -486,21 +486,6 @@
"long_description": "Geeft aan of je Tibber-abonnement momenteel actief is, beëindigd of wacht op activering. Een 'Actief'-status betekent dat je actief elektriciteit via Tibber afneemt.",
"usage_tips": "Gebruik dit om je abonnementsstatus te monitoren. Stel meldingen in als de status verandert van 'Actief' om ononderbroken service te waarborgen."
},
"day_pattern_yesterday": {
"description": "Gedetecteerd prijspatroon van gisterens elektriciteitsprijzen",
"long_description": "Classificeert gisteren in een prijspatroon: Dal (goedkoop in het midden), Piek (duur in het midden), Dubbel Dal (twee goedkope perioden), Dubbele Piek (twee dure perioden), Vlak (weinig variatie), Stijgend, Dalend of Gemengd. De confidence- en CV-attributen tonen hoe betrouwbaar het patroon is gedetecteerd.",
"usage_tips": "Gebruik het patroon van gisteren om automations te verfijnen: een Daldag herhaalt zich vaak de volgende dag en suggereert om goedkope middaguren in te plannen."
},
"day_pattern_today": {
"description": "Gedetecteerd prijspatroon van de huidige elektriciteitsprijzen",
"long_description": "Classificeert vandaag in een prijspatroon: Dal (goedkoop 's middags), Piek (duur 's middags), Dubbel Dal (W-vorm), Dubbele Piek (M-vorm), Vlak, Stijgend, Dalend of Gemengd. Attributen omvatten confidence (01), variatiecoëfficiënt, kniepunttijden en dagsegmenten.",
"usage_tips": "Gebruik het dagpatroon om verbruik te verschuiven. Daldag: draai vaatwasser, wasmachine of laad de EV 's middags. Piekdag: gebruik apparaten vroeg in de ochtend of laat in de avond. Gebruik valley_start en valley_end voor precieze automations."
},
"day_pattern_tomorrow": {
"description": "Gedetecteerd prijspatroon van de elektriciteitsprijzen van morgen",
"long_description": "Classificeert morgen (zodra data beschikbaar is, doorgaans na 13:00) in een prijspatroon met hetzelfde algoritme als vandaag. De attributen valley_start/valley_end of peak_start/peak_end geven kniepunttijden voor het primaire extremum.",
"usage_tips": "Stel avondautomations in die het patroon van morgen lezen en warmtepomp, autolader of boiler vooraf configureren. Combineer met de tomorrow_data_available binaire sensor."
},
"chart_data_export": {
"description": "Data-export voor dashboard-integraties",
"long_description": "Deze sensor roept de get_chartdata-service aan met jouw geconfigureerde YAML-configuratie en stelt het resultaat beschikbaar als entiteitsattributen. De status toont 'ready' wanneer data beschikbaar is, 'error' bij fouten, of 'pending' voor de eerste aanroep. Perfekt voor dashboard-integraties zoals ApexCharts die prijsgegevens uit entiteitsattributen moeten lezen.",

View file

@ -486,21 +486,6 @@
"long_description": "Visar om ditt Tibber-abonnemang för närvarande är aktivt, har avslutats eller väntar på aktivering. En status 'Aktiv' betyder att du aktivt tar emot elektricitet genom Tibber.",
"usage_tips": "Använd detta för att övervaka din abonnemangsstatus. Ställ in varningar om statusen ändras från 'Aktiv' för att säkerställa oavbruten service."
},
"day_pattern_yesterday": {
"description": "Detekterat prismönster för gårdagens elpriser",
"long_description": "Klassificerar igår i ett prismönster: Dal (billigt på mitten), Topp (dyrt på mitten), Dubbeldal (W-form, två billiga perioder), Dubbeltopp (M-form, två dyra toppar), Flat (liten variation), Stigande, Fallande eller Blandad. Konfidensen och CV-attributen visar hur tillförlitligt mönstret detekterades.",
"usage_tips": "Använd gårdagens mönster för att förfina automationer: ett Dalmönster upprepas ofta nästa dag och tyder på att du bör förplanera billiga middagstimmar."
},
"day_pattern_today": {
"description": "Detekterat prismönster för dagens elpriser",
"long_description": "Klassificerar idag i ett prismönster: Dal (billigt på middagen), Topp (dyrt på middagen), Dubbeldal (W-form), Dubbeltopp (M-form), Flat, Stigande, Fallande eller Blandad. Attributen inkluderar konfidenspoäng (01), variationskoefficient, knäpunkttider och dagsegment.",
"usage_tips": "Använd dagens mönster för att flytta förbrukning. Daldag: kör diskmaskinen, tvättmaskinen eller ladda elbilen på middagen. Toppdag: kör apparater tidigt på morgonen eller sent på kvällen. Använd valley_start och valley_end för precisa automationer."
},
"day_pattern_tomorrow": {
"description": "Detekterat prismönster för morgondagens elpriser",
"long_description": "Klassificerar imorgon (när data finns tillgänglig, vanligtvis efter 13:00) i ett prismönster med samma algoritm som idag. Attributen valley_start/valley_end eller peak_start/peak_end ger knäpunkttider för det primära extremvärdet.",
"usage_tips": "Ställ in kvällsautomationer som läser morgondagens mönster och förkonfigurerar värmepump, billaddare eller varmvattenberedare. Kombinera med tomorrow_data_available-binärsensorn."
},
"chart_data_export": {
"description": "Dataexport för dashboard-integrationer",
"long_description": "Denna sensor anropar get_chartdata-tjänsten med din konfigurerade YAML-konfiguration och exponerar resultatet som entitetsattribut. Statusen visar 'ready' när data är tillgänglig, 'error' vid fel, eller 'pending' före första anropet. Perfekt för dashboard-integrationer som ApexCharts som behöver läsa prisdata från entitetsattribut.",

View file

@ -28,52 +28,6 @@
},
"refresh_user_data": {
"service": "mdi:refresh"
},
"find_cheapest_block": {
"service": "mdi:washing-machine",
"sections": {
"search_range": "mdi:calendar-search",
"time_alternatives": "mdi:clock-time-eight-outline",
"price_filter": "mdi:filter-variant",
"output": "mdi:tune-variant"
}
},
"find_most_expensive_block": {
"service": "mdi:lightning-bolt-circle",
"sections": {
"search_range": "mdi:calendar-search",
"time_alternatives": "mdi:clock-time-eight-outline",
"price_filter": "mdi:filter-variant",
"output": "mdi:tune-variant"
}
},
"find_cheapest_hours": {
"service": "mdi:ev-station",
"sections": {
"search_range": "mdi:calendar-search",
"time_alternatives": "mdi:clock-time-eight-outline",
"price_filter": "mdi:filter-variant",
"output": "mdi:tune-variant"
}
},
"find_most_expensive_hours": {
"service": "mdi:flash-alert",
"sections": {
"search_range": "mdi:calendar-search",
"time_alternatives": "mdi:clock-time-eight-outline",
"price_filter": "mdi:filter-variant",
"output": "mdi:tune-variant"
}
},
"find_cheapest_schedule": {
"service": "mdi:calendar-check",
"sections": {
"scheduling_options": "mdi:format-list-numbered",
"search_range": "mdi:calendar-search",
"time_alternatives": "mdi:clock-time-eight-outline",
"price_filter": "mdi:filter-variant",
"output": "mdi:tune-variant"
}
}
}
}

View file

@ -44,7 +44,6 @@ from .daily_stat import add_statistics_attributes
from .future import add_next_avg_attributes, get_future_prices
from .interval import add_current_interval_price_attributes
from .lifecycle import build_lifecycle_attributes
from .metadata import get_day_pattern_attributes
from .timing import _is_timing_or_volatility_sensor
from .trend import _add_cached_trend_attributes, _add_timing_or_volatility_attributes
from .volatility import add_volatility_type_attributes, get_prices_for_volatility
@ -73,7 +72,7 @@ __all__ = [
]
def build_sensor_attributes( # noqa: PLR0912
def build_sensor_attributes(
key: str,
coordinator: TibberPricesDataUpdateCoordinator,
native_value: Any,
@ -190,12 +189,6 @@ def build_sensor_attributes( # noqa: PLR0912
elif _is_timing_or_volatility_sensor(key):
_add_timing_or_volatility_attributes(attributes, key, cached_data, native_value, time=time)
elif key in ("day_pattern_yesterday", "day_pattern_today", "day_pattern_tomorrow"):
day = key.removeprefix("day_pattern_")
day_attrs = get_day_pattern_attributes(coordinator, day)
if day_attrs:
attributes.update(day_attrs)
# For current_interval_price_level, add the original level as attribute
if key == "current_interval_price_level" and cached_data.get("last_price_level") is not None:
attributes["level_id"] = cached_data["last_price_level"]

View file

@ -2,7 +2,7 @@
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from typing import TYPE_CHECKING
from custom_components.tibber_prices.utils.price import find_price_data_for_interval
@ -35,77 +35,3 @@ def get_current_interval_data(
now = time.now()
return find_price_data_for_interval(coordinator.data, now, time=time)
def get_day_pattern_attributes(
coordinator: TibberPricesDataUpdateCoordinator,
day: str,
) -> dict[str, Any] | None:
"""
Build attributes for a day_pattern_* sensor.
Returns the full DayPatternDict fields (except "pattern" which is the sensor
state) plus ISO-formatted datetime fields.
Args:
coordinator: The data update coordinator.
day: One of "yesterday", "today", "tomorrow".
time: TibberPricesTimeService instance.
Returns:
Attribute dict or None if pattern data is unavailable.
"""
if not coordinator.data:
return None
day_patterns = coordinator.data.get("dayPatterns")
if not day_patterns:
return None
day_data: dict[str, Any] | None = day_patterns.get(day)
if not day_data:
return None
def _iso(val: object) -> str | None:
"""Convert datetime to ISO string, pass strings through, return None otherwise."""
if val is None:
return None
if isinstance(val, str):
return val
if hasattr(val, "isoformat"):
return val.isoformat() # type: ignore[return-value]
return None
attrs: dict[str, Any] = {
"confidence": day_data.get("confidence"),
"day_cv_percent": day_data.get("day_cv_percent"),
}
# Optional primary extreme time
extreme_time = _iso(day_data.get("extreme_time"))
if extreme_time is not None:
attrs["extreme_time"] = extreme_time
# VALLEY-specific knee points
valley_start = _iso(day_data.get("valley_start"))
valley_end = _iso(day_data.get("valley_end"))
if valley_start is not None:
attrs["valley_start"] = valley_start
if valley_end is not None:
attrs["valley_end"] = valley_end
# PEAK-specific knee points
peak_start = _iso(day_data.get("peak_start"))
peak_end = _iso(day_data.get("peak_end"))
if peak_start is not None:
attrs["peak_start"] = peak_start
if peak_end is not None:
attrs["peak_end"] = peak_end
# Segments (list of monotone regions)
segments = day_data.get("segments")
if segments:
attrs["segments"] = segments
return attrs or None

View file

@ -113,27 +113,3 @@ class TibberPricesMetadataCalculator(TibberPricesBaseCalculator):
return value.lower()
return value
def get_day_pattern_value(self, day: str) -> str | None:
"""
Get the detected price pattern for a calendar day.
Args:
day: One of "yesterday", "today", or "tomorrow".
Returns:
Pattern string (e.g. "valley", "peak", "flat") or None if not available.
"""
if not self.coordinator.data:
return None
day_patterns = self.coordinator.data.get("dayPatterns")
if not day_patterns:
return None
day_data = day_patterns.get(day)
if not day_data:
return None
return day_data.get("pattern")

View file

@ -865,70 +865,7 @@ PEAK_PRICE_TIMING_SENSORS = (
),
)
# 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)
# 8. DIAGNOSTIC SENSORS (data availability and metadata)
# ----------------------------------------------------------------------------
DIAGNOSTIC_SENSORS = (
@ -1118,6 +1055,5 @@ ENTITY_DESCRIPTIONS = (
*VOLATILITY_SENSORS,
*BEST_PRICE_TIMING_SENSORS,
*PEAK_PRICE_TIMING_SENSORS,
*DAY_PATTERN_SENSORS,
*DIAGNOSTIC_SENSORS,
)

View file

@ -238,10 +238,6 @@ def get_value_getter_mapping( # noqa: PLR0913 - needs all calculators as parame
),
# Subscription sensors (via MetadataCalculator)
"subscription_status": lambda: metadata_calculator.get_subscription_value("status"),
# Day pattern sensors (via MetadataCalculator)
"day_pattern_yesterday": lambda: metadata_calculator.get_day_pattern_value("yesterday"),
"day_pattern_today": lambda: metadata_calculator.get_day_pattern_value("today"),
"day_pattern_tomorrow": lambda: metadata_calculator.get_day_pattern_value("tomorrow"),
# Volatility sensors (via VolatilityCalculator)
"today_volatility": lambda: volatility_calculator.get_volatility_value(volatility_type="today"),
"tomorrow_volatility": lambda: volatility_calculator.get_volatility_value(volatility_type="tomorrow"),

View file

@ -1,7 +1,7 @@
get_price:
fields:
entry_id:
required: false
required: true
example: "1234567890abcdef"
selector:
config_entry:
@ -19,7 +19,7 @@ get_price:
get_apexcharts_yaml:
fields:
entry_id:
required: false
required: true
example: "1234567890abcdef"
selector:
config_entry:
@ -73,7 +73,7 @@ get_chartdata:
general:
fields:
entry_id:
required: false
required: true
example: "1234567890abcdef"
selector:
config_entry:
@ -274,772 +274,12 @@ get_chartdata:
refresh_user_data:
fields:
entry_id:
required: false
example: "1234567890abcdef"
selector:
config_entry:
integration: tibber_prices
find_cheapest_block:
fields:
entry_id:
required: false
example: "1234567890abcdef"
selector:
config_entry:
integration: tibber_prices
duration:
required: true
selector:
duration:
search_range:
fields:
search_scope:
required: false
selector:
select:
options:
- today
- tomorrow
- remaining_today
- next_24h
- next_48h
translation_key: search_scope
search_start:
required: false
example: "2026-04-11T06:00:00+02:00"
selector:
datetime:
search_end:
required: false
example: "2026-04-12T00:00:00+02:00"
selector:
datetime:
time_alternatives:
collapsed: true
fields:
search_start_time:
required: false
example: "06:00:00"
selector:
time:
search_start_day_offset:
required: false
default: 0
selector:
number:
min: -7
max: 2
mode: box
search_end_time:
required: false
example: "23:00:00"
selector:
time:
search_end_day_offset:
required: false
default: 0
selector:
number:
min: -7
max: 2
mode: box
search_start_offset_minutes:
required: false
example: 60
selector:
number:
min: -10080
max: 10080
unit_of_measurement: min
mode: box
search_end_offset_minutes:
required: false
example: 480
selector:
number:
min: -10080
max: 10080
unit_of_measurement: min
mode: box
include_current_interval:
required: false
default: true
selector:
boolean:
price_filter:
collapsed: true
fields:
max_price_level:
required: false
selector:
select:
options:
- very_cheap
- cheap
- normal
- expensive
- very_expensive
translation_key: level_filter
min_price_level:
required: false
selector:
select:
options:
- very_cheap
- cheap
- normal
- expensive
- very_expensive
translation_key: level_filter
output:
collapsed: true
fields:
power_profile:
required: false
selector:
object:
include_comparison_details:
required: false
default: false
selector:
boolean:
use_base_unit:
required: false
default: false
selector:
boolean:
find_most_expensive_block:
fields:
entry_id:
required: false
example: "1234567890abcdef"
selector:
config_entry:
integration: tibber_prices
duration:
required: true
selector:
duration:
search_range:
fields:
search_scope:
required: false
selector:
select:
options:
- today
- tomorrow
- remaining_today
- next_24h
- next_48h
translation_key: search_scope
search_start:
required: false
example: "2026-04-11T06:00:00+02:00"
selector:
datetime:
search_end:
required: false
example: "2026-04-12T00:00:00+02:00"
selector:
datetime:
time_alternatives:
collapsed: true
fields:
search_start_time:
required: false
example: "06:00:00"
selector:
time:
search_start_day_offset:
required: false
default: 0
selector:
number:
min: -7
max: 2
mode: box
search_end_time:
required: false
example: "23:00:00"
selector:
time:
search_end_day_offset:
required: false
default: 0
selector:
number:
min: -7
max: 2
mode: box
search_start_offset_minutes:
required: false
example: 60
selector:
number:
min: -10080
max: 10080
unit_of_measurement: min
mode: box
search_end_offset_minutes:
required: false
example: 480
selector:
number:
min: -10080
max: 10080
unit_of_measurement: min
mode: box
include_current_interval:
required: false
default: true
selector:
boolean:
price_filter:
collapsed: true
fields:
max_price_level:
required: false
selector:
select:
options:
- very_cheap
- cheap
- normal
- expensive
- very_expensive
translation_key: level_filter
min_price_level:
required: false
selector:
select:
options:
- very_cheap
- cheap
- normal
- expensive
- very_expensive
translation_key: level_filter
output:
collapsed: true
fields:
power_profile:
required: false
selector:
object:
include_comparison_details:
required: false
default: false
selector:
boolean:
use_base_unit:
required: false
default: false
selector:
boolean:
find_cheapest_hours:
fields:
entry_id:
required: false
example: "1234567890abcdef"
selector:
config_entry:
integration: tibber_prices
duration:
required: true
selector:
duration:
min_segment_duration:
required: false
selector:
duration:
search_range:
fields:
search_scope:
required: false
selector:
select:
options:
- today
- tomorrow
- remaining_today
- next_24h
- next_48h
translation_key: search_scope
search_start:
required: false
example: "2026-04-11T06:00:00+02:00"
selector:
datetime:
search_end:
required: false
example: "2026-04-12T00:00:00+02:00"
selector:
datetime:
time_alternatives:
collapsed: true
fields:
search_start_time:
required: false
example: "06:00:00"
selector:
time:
search_start_day_offset:
required: false
default: 0
selector:
number:
min: -7
max: 2
mode: box
search_end_time:
required: false
example: "23:00:00"
selector:
time:
search_end_day_offset:
required: false
default: 0
selector:
number:
min: -7
max: 2
mode: box
search_start_offset_minutes:
required: false
example: 60
selector:
number:
min: -10080
max: 10080
unit_of_measurement: min
mode: box
search_end_offset_minutes:
required: false
example: 480
selector:
number:
min: -10080
max: 10080
unit_of_measurement: min
mode: box
include_current_interval:
required: false
default: true
selector:
boolean:
price_filter:
collapsed: true
fields:
max_price_level:
required: false
selector:
select:
options:
- very_cheap
- cheap
- normal
- expensive
- very_expensive
translation_key: level_filter
min_price_level:
required: false
selector:
select:
options:
- very_cheap
- cheap
- normal
- expensive
- very_expensive
translation_key: level_filter
output:
collapsed: true
fields:
power_profile:
required: false
selector:
object:
include_comparison_details:
required: false
default: false
selector:
boolean:
use_base_unit:
required: false
default: false
selector:
boolean:
find_most_expensive_hours:
fields:
entry_id:
required: false
example: "1234567890abcdef"
selector:
config_entry:
integration: tibber_prices
duration:
required: true
selector:
duration:
min_segment_duration:
required: false
selector:
duration:
search_range:
fields:
search_scope:
required: false
selector:
select:
options:
- today
- tomorrow
- remaining_today
- next_24h
- next_48h
translation_key: search_scope
search_start:
required: false
example: "2026-04-11T06:00:00+02:00"
selector:
datetime:
search_end:
required: false
example: "2026-04-12T00:00:00+02:00"
selector:
datetime:
time_alternatives:
collapsed: true
fields:
search_start_time:
required: false
example: "06:00:00"
selector:
time:
search_start_day_offset:
required: false
default: 0
selector:
number:
min: -7
max: 2
mode: box
search_end_time:
required: false
example: "23:00:00"
selector:
time:
search_end_day_offset:
required: false
default: 0
selector:
number:
min: -7
max: 2
mode: box
search_start_offset_minutes:
required: false
example: 60
selector:
number:
min: -10080
max: 10080
unit_of_measurement: min
mode: box
search_end_offset_minutes:
required: false
example: 480
selector:
number:
min: -10080
max: 10080
unit_of_measurement: min
mode: box
include_current_interval:
required: false
default: true
selector:
boolean:
price_filter:
collapsed: true
fields:
max_price_level:
required: false
selector:
select:
options:
- very_cheap
- cheap
- normal
- expensive
- very_expensive
translation_key: level_filter
min_price_level:
required: false
selector:
select:
options:
- very_cheap
- cheap
- normal
- expensive
- very_expensive
translation_key: level_filter
output:
collapsed: true
fields:
power_profile:
required: false
selector:
object:
include_comparison_details:
required: false
default: false
selector:
boolean:
use_base_unit:
required: false
default: false
selector:
boolean:
find_cheapest_schedule:
fields:
entry_id:
required: false
example: "1234567890abcdef"
selector:
config_entry:
integration: tibber_prices
tasks:
required: true
example: '[{"name": "dishwasher", "duration": "02:00:00"}, {"name": "washing_machine", "duration": "01:30:00"}]'
selector:
object:
scheduling_options:
fields:
gap_minutes:
required: false
default: 0
selector:
number:
min: 0
max: 120
unit_of_measurement: min
mode: box
search_range:
fields:
search_scope:
required: false
selector:
select:
options:
- today
- tomorrow
- remaining_today
- next_24h
- next_48h
translation_key: search_scope
search_start:
required: false
example: "2026-04-11T06:00:00+02:00"
selector:
datetime:
search_end:
required: false
example: "2026-04-12T00:00:00+02:00"
selector:
datetime:
time_alternatives:
collapsed: true
fields:
search_start_time:
required: false
example: "06:00:00"
selector:
time:
search_start_day_offset:
required: false
default: 0
selector:
number:
min: -7
max: 2
mode: box
search_end_time:
required: false
example: "23:00:00"
selector:
time:
search_end_day_offset:
required: false
default: 0
selector:
number:
min: -7
max: 2
mode: box
search_start_offset_minutes:
required: false
example: 60
selector:
number:
min: -10080
max: 10080
unit_of_measurement: min
mode: box
search_end_offset_minutes:
required: false
example: 480
selector:
number:
min: -10080
max: 10080
unit_of_measurement: min
mode: box
price_filter:
collapsed: true
fields:
max_price_level:
required: false
selector:
select:
options:
- very_cheap
- cheap
- normal
- expensive
- very_expensive
translation_key: level_filter
min_price_level:
required: false
selector:
select:
options:
- very_cheap
- cheap
- normal
- expensive
- very_expensive
translation_key: level_filter
output:
collapsed: true
fields:
use_base_unit:
required: false
default: false
selector:
boolean:
search_start:
required: false
example: "2026-04-11T06:00:00+02:00"
selector:
datetime:
search_end:
required: false
example: "2026-04-12T00:00:00+02:00"
selector:
datetime:
search_start_time:
required: false
example: "06:00:00"
selector:
time:
search_start_day_offset:
required: false
default: 0
selector:
number:
min: -7
max: 2
mode: box
search_end_time:
required: false
example: "23:00:00"
selector:
time:
search_end_day_offset:
required: false
default: 0
selector:
number:
min: -7
max: 2
mode: box
search_start_offset_minutes:
required: false
example: 60
selector:
number:
min: -10080
max: 10080
unit_of_measurement: min
mode: box
search_end_offset_minutes:
required: false
example: 480
selector:
number:
min: -10080
max: 10080
unit_of_measurement: min
mode: box
search_scope:
required: false
selector:
select:
options:
- today
- tomorrow
- remaining_today
- next_24h
- next_48h
max_price_level:
required: false
selector:
select:
options:
- very_cheap
- cheap
- normal
- expensive
- very_expensive
min_price_level:
required: false
selector:
select:
options:
- very_cheap
- cheap
- normal
- expensive
- very_expensive
include_comparison_details:
required: false
default: false
selector:
boolean:
power_profile:
required: false
selector:
object:
include_current_interval:
required: false
default: true
selector:
boolean:
use_base_unit:
required: false
default: false
selector:
boolean:
debug_clear_tomorrow:
fields:
entry_id:

View file

@ -25,31 +25,6 @@ from typing import TYPE_CHECKING
from custom_components.tibber_prices.const import DOMAIN
from homeassistant.core import SupportsResponse, callback
from .find_cheapest_block import (
FIND_CHEAPEST_BLOCK_SERVICE_NAME,
FIND_CHEAPEST_BLOCK_SERVICE_SCHEMA,
handle_find_cheapest_block,
)
from .find_cheapest_hours import (
FIND_CHEAPEST_HOURS_SERVICE_NAME,
FIND_CHEAPEST_HOURS_SERVICE_SCHEMA,
handle_find_cheapest_hours,
)
from .find_cheapest_schedule import (
FIND_CHEAPEST_SCHEDULE_SERVICE_NAME,
FIND_CHEAPEST_SCHEDULE_SERVICE_SCHEMA,
handle_find_cheapest_schedule,
)
from .find_most_expensive_block import (
FIND_MOST_EXPENSIVE_BLOCK_SERVICE_NAME,
FIND_MOST_EXPENSIVE_BLOCK_SERVICE_SCHEMA,
handle_find_most_expensive_block,
)
from .find_most_expensive_hours import (
FIND_MOST_EXPENSIVE_HOURS_SERVICE_NAME,
FIND_MOST_EXPENSIVE_HOURS_SERVICE_SCHEMA,
handle_find_most_expensive_hours,
)
from .get_apexcharts_yaml import (
APEXCHARTS_SERVICE_SCHEMA,
APEXCHARTS_YAML_SERVICE_NAME,
@ -98,41 +73,6 @@ def async_setup_services(hass: HomeAssistant) -> None:
schema=GET_PRICE_SERVICE_SCHEMA,
supports_response=SupportsResponse.ONLY,
)
hass.services.async_register(
DOMAIN,
FIND_CHEAPEST_BLOCK_SERVICE_NAME,
handle_find_cheapest_block,
schema=FIND_CHEAPEST_BLOCK_SERVICE_SCHEMA,
supports_response=SupportsResponse.ONLY,
)
hass.services.async_register(
DOMAIN,
FIND_CHEAPEST_HOURS_SERVICE_NAME,
handle_find_cheapest_hours,
schema=FIND_CHEAPEST_HOURS_SERVICE_SCHEMA,
supports_response=SupportsResponse.ONLY,
)
hass.services.async_register(
DOMAIN,
FIND_CHEAPEST_SCHEDULE_SERVICE_NAME,
handle_find_cheapest_schedule,
schema=FIND_CHEAPEST_SCHEDULE_SERVICE_SCHEMA,
supports_response=SupportsResponse.ONLY,
)
hass.services.async_register(
DOMAIN,
FIND_MOST_EXPENSIVE_BLOCK_SERVICE_NAME,
handle_find_most_expensive_block,
schema=FIND_MOST_EXPENSIVE_BLOCK_SERVICE_SCHEMA,
supports_response=SupportsResponse.ONLY,
)
hass.services.async_register(
DOMAIN,
FIND_MOST_EXPENSIVE_HOURS_SERVICE_NAME,
handle_find_most_expensive_hours,
schema=FIND_MOST_EXPENSIVE_HOURS_SERVICE_SCHEMA,
supports_response=SupportsResponse.ONLY,
)
hass.services.async_register(
DOMAIN,
REFRESH_USER_DATA_SERVICE_NAME,

View file

@ -1,296 +0,0 @@
"""
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)

View file

@ -1,334 +0,0 @@
"""
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)

View file

@ -1,331 +0,0 @@
"""
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

View file

@ -1,26 +0,0 @@
"""
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)

View file

@ -1,26 +0,0 @@
"""
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)

View file

@ -25,6 +25,7 @@ import voluptuous as vol
from custom_components.tibber_prices.const import (
CONF_CURRENCY_DISPLAY_MODE,
DISPLAY_MODE_SUBUNIT,
DOMAIN,
PRICE_LEVEL_CHEAP,
PRICE_LEVEL_EXPENSIVE,
PRICE_LEVEL_NORMAL,
@ -36,6 +37,7 @@ from custom_components.tibber_prices.const import (
get_display_unit_string,
get_translation,
)
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_registry import (
EntityRegistry,
@ -58,7 +60,7 @@ ATTR_ENTRY_ID: Final = "entry_id"
# Service schema
APEXCHARTS_SERVICE_SCHEMA = vol.Schema(
{
vol.Optional(ATTR_ENTRY_ID, default=""): cv.string,
vol.Required(ATTR_ENTRY_ID): cv.string,
vol.Optional("day"): vol.In(["yesterday", "today", "tomorrow", "rolling_window", "rolling_window_autozoom"]),
vol.Optional("level_type", default="rating_level"): vol.In(["rating_level", "level"]),
vol.Optional("resolution", default="interval"): vol.In(["interval", "hourly"]),
@ -288,7 +290,10 @@ async def handle_apexcharts_yaml(call: ServiceCall) -> dict[str, Any]: # noqa:
"""
hass = call.hass
entry_id_input: str = call.data.get(ATTR_ENTRY_ID, "")
entry_id_raw = call.data.get(ATTR_ENTRY_ID)
if entry_id_raw is None:
raise ServiceValidationError(translation_domain=DOMAIN, translation_key="missing_entry_id")
entry_id: str = str(entry_id_raw)
day = call.data.get("day") # Can be None (rolling window mode)
level_type = call.data.get("level_type", "rating_level")
@ -300,8 +305,7 @@ async def handle_apexcharts_yaml(call: ServiceCall) -> dict[str, Any]: # noqa:
user_language = hass.config.language or "en"
# Get coordinator to access price data (for currency) and config entry for display settings
config_entry, coordinator, _ = get_entry_and_data(hass, entry_id_input)
entry_id: str = config_entry.entry_id
config_entry, coordinator, _ = get_entry_and_data(hass, entry_id)
# Get currency from coordinator data
currency = coordinator.data.get("currency", "EUR")

View file

@ -270,7 +270,7 @@ ATTR_ENTRY_ID: Final = "entry_id"
# Service schema
CHARTDATA_SERVICE_SCHEMA: Final = vol.Schema(
{
vol.Optional(ATTR_ENTRY_ID, default=""): str,
vol.Required(ATTR_ENTRY_ID): str,
vol.Optional(ATTR_DAY): vol.All(vol.Coerce(list), [vol.In(["yesterday", "today", "tomorrow"])]),
vol.Optional("resolution", default="interval"): vol.In(["interval", "hourly"]),
vol.Optional("output_format", default="array_of_objects"): vol.In(["array_of_objects", "array_of_arrays"]),
@ -346,7 +346,10 @@ async def handle_chartdata(call: ServiceCall) -> dict[str, Any]: # noqa: PLR091
"""
hass = call.hass
entry_id: str = call.data.get(ATTR_ENTRY_ID, "")
entry_id_raw = call.data.get(ATTR_ENTRY_ID)
if entry_id_raw is None:
raise ServiceValidationError(translation_domain=DOMAIN, translation_key="missing_entry_id")
entry_id: str = str(entry_id_raw)
# Get coordinator to check data availability
_, coordinator, _ = get_entry_and_data(hass, entry_id)
@ -390,39 +393,6 @@ async def handle_chartdata(call: ServiceCall) -> dict[str, Any]: # noqa: PLR091
level_filter = call.data.get("level_filter")
rating_level_filter = call.data.get("rating_level_filter")
# --- Parameter dependency validation ---
# level_filter and rating_level_filter are mutually exclusive
if level_filter and rating_level_filter:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="level_and_rating_filter_conflict",
)
has_filter = bool(level_filter or rating_level_filter)
# insert_nulls modes "segments"/"all" require a level or rating filter
if insert_nulls != "none" and not has_filter:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="insert_nulls_requires_filter",
translation_placeholders={"mode": insert_nulls},
)
# connect_segments requires insert_nulls="segments" (with a filter)
if connect_segments and insert_nulls != "segments":
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="connect_segments_requires_segments_mode",
)
# array_fields is only meaningful with array_of_arrays format
if call.data.get("array_fields") and output_format != "array_of_arrays":
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="array_fields_requires_array_format",
)
# === METADATA-ONLY MODE ===
# Early return: calculate and return only metadata, skip all data processing
if metadata == "only":

View file

@ -36,7 +36,7 @@ GET_PRICE_SERVICE_NAME = "get_price"
GET_PRICE_SERVICE_SCHEMA = vol.Schema(
{
vol.Optional("entry_id", default=""): cv.string,
vol.Required("entry_id"): cv.string,
vol.Required("start_time"): cv.datetime,
vol.Required("end_time"): cv.datetime,
}
@ -70,7 +70,7 @@ async def handle_get_price(call: ServiceCall) -> ServiceResponse:
"""
hass: HomeAssistant = call.hass
entry_id: str = call.data.get("entry_id", "")
entry_id: str = call.data["entry_id"]
start_time: datetime = call.data["start_time"]
end_time: datetime = call.data["end_time"]
@ -126,13 +126,6 @@ async def handle_get_price(call: ServiceCall) -> ServiceResponse:
# Step 2: Convert to home timezone
end_time = end_time.astimezone(home_tz)
# Validate: end must be after start
if end_time <= start_time:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="end_before_start",
)
_LOGGER.info(
"get_price service called: entry_id=%s, home_id=%s, range=%s to %s",
entry_id,

View file

@ -2,198 +2,52 @@
Shared utilities for service handlers.
This module provides common helper functions used across multiple service handlers,
such as entry validation, data extraction, timezone resolution, and search range handling.
such as entry validation and data extraction.
Functions:
get_entry_and_data: Validate config entry and extract coordinator data
has_tomorrow_data: Check if tomorrow's price data is available
resolve_home_timezone: Extract home timezone from coordinator
localize_to_home_tz: Localize datetime to Tibber home timezone
calculate_end_of_tomorrow: Calculate end of tomorrow in home timezone
floor_to_quarter_hour: Floor datetime to quarter-hour boundary
resolve_search_range: Resolve search start/end from various input formats
filter_intervals_by_price_level: Filter intervals by Tibber price level
VALID_SEARCH_SCOPES: Set of valid search_scope shorthand values
PRICE_LEVEL_ORDER: Ordered tuple of price levels (lowest to highest)
Used by:
- services/chartdata.py: Chart data export service
- services/apexcharts.py: ApexCharts YAML generation
- services/refresh_user_data.py: User data refresh
- services/find_cheapest_block.py: Block service (cheapest + most expensive)
- services/find_cheapest_hours.py: Hours service (cheapest + most expensive)
- services/find_most_expensive_block.py: Most expensive block wrapper
- services/find_most_expensive_hours.py: Most expensive hours wrapper
"""
from __future__ import annotations
from datetime import datetime, timedelta
from datetime import time as dt_time
from typing import TYPE_CHECKING, Any
from custom_components.tibber_prices.const import DOMAIN
from custom_components.tibber_prices.coordinator.helpers import get_intervals_for_day_offsets
from homeassistant.exceptions import ServiceValidationError
from homeassistant.util import dt as dt_utils
if TYPE_CHECKING:
from zoneinfo import ZoneInfo
from custom_components.tibber_prices.coordinator import TibberPricesDataUpdateCoordinator
from homeassistant.core import HomeAssistant
# Interval duration in minutes (quarter-hourly resolution)
INTERVAL_MINUTES = 15
# Valid scopes for the search_scope shorthand parameter
VALID_SEARCH_SCOPES = frozenset({"today", "tomorrow", "remaining_today", "next_24h", "next_48h"})
# Price level hierarchy (lowest to highest)
PRICE_LEVEL_ORDER = ("VERY_CHEAP", "CHEAP", "NORMAL", "EXPENSIVE", "VERY_EXPENSIVE")
_PRICE_LEVEL_RANK: dict[str, int] = {lvl: i for i, lvl in enumerate(PRICE_LEVEL_ORDER)}
# Parameters that define explicit search range boundaries
_EXPLICIT_RANGE_PARAMS = frozenset(
{
"search_start",
"search_end",
"search_start_time",
"search_end_time",
"search_start_offset_minutes",
"search_end_offset_minutes",
"search_start_day_offset",
"search_end_day_offset",
}
)
def validate_search_params(call_data: dict[str, Any]) -> None:
"""
Validate search range parameter combinations.
Checks for mutually exclusive parameters and required co-dependencies.
Must be called before resolve_search_range().
Raises:
ServiceValidationError: If parameter combinations are invalid
"""
has_scope = "search_scope" in call_data
# search_scope conflicts with all explicit range parameters
if has_scope:
# day_offset params always appear (voluptuous defaults to 0), exclude from conflict check
conflicts = _EXPLICIT_RANGE_PARAMS - {"search_start_day_offset", "search_end_day_offset"}
conflicting = [p for p in conflicts if p in call_data]
if conflicting:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="scope_conflicts_with_range",
translation_placeholders={"params": ", ".join(sorted(conflicting))},
)
# day_offset without matching time parameter is meaningless
# Schema defaults provide 0, but user explicitly setting non-zero without time is an error.
# We detect explicit usage by checking for non-default values when time is absent.
if "search_start_time" not in call_data and call_data.get("search_start_day_offset", 0) != 0:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="day_offset_requires_time",
translation_placeholders={"offset_param": "search_start_day_offset", "time_param": "search_start_time"},
)
if "search_end_time" not in call_data and call_data.get("search_end_day_offset", 0) != 0:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="day_offset_requires_time",
translation_placeholders={"offset_param": "search_end_day_offset", "time_param": "search_end_time"},
)
def validate_price_level_range(
min_price_level: str | None,
max_price_level: str | None,
) -> None:
"""
Validate that min_price_level <= max_price_level in the level hierarchy.
Raises:
ServiceValidationError: If min level is higher than max level
"""
if min_price_level is None or max_price_level is None:
return
min_rank = _PRICE_LEVEL_RANK.get(min_price_level.upper(), 0)
max_rank = _PRICE_LEVEL_RANK.get(max_price_level.upper(), len(PRICE_LEVEL_ORDER) - 1)
if min_rank > max_rank:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="min_level_exceeds_max",
translation_placeholders={"min_level": min_price_level, "max_level": max_price_level},
)
def validate_power_profile_length(
power_profile: list[int] | None,
duration_intervals: int,
) -> None:
"""
Validate that power_profile length matches the number of intervals.
Raises:
ServiceValidationError: If lengths don't match
"""
if power_profile is None:
return
if len(power_profile) != duration_intervals:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="power_profile_length_mismatch",
translation_placeholders={
"profile_length": str(len(power_profile)),
"interval_count": str(duration_intervals),
"duration_minutes": str(duration_intervals * INTERVAL_MINUTES),
},
)
def get_entry_and_data(hass: HomeAssistant, entry_id: str) -> tuple[Any, Any, dict]:
"""
Validate entry and extract coordinator and data.
If entry_id is empty, auto-selects the single config entry for this domain.
Raises an error if there are zero or multiple entries and no entry_id is given.
Args:
hass: Home Assistant instance
entry_id: Config entry ID to validate (empty string to auto-select)
entry_id: Config entry ID to validate
Returns:
Tuple of (entry, coordinator, data)
Raises:
ServiceValidationError: If entry cannot be resolved
ServiceValidationError: If entry_id is missing or invalid
"""
if not entry_id:
entries = hass.config_entries.async_entries(DOMAIN)
if len(entries) == 1:
entry = entries[0]
elif len(entries) == 0:
raise ServiceValidationError(translation_domain=DOMAIN, translation_key="no_entries_found")
else:
raise ServiceValidationError(translation_domain=DOMAIN, translation_key="multiple_entries_no_entry_id")
else:
entry = next(
(e for e in hass.config_entries.async_entries(DOMAIN) if e.entry_id == entry_id),
None,
)
raise ServiceValidationError(translation_domain=DOMAIN, translation_key="missing_entry_id")
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:
raise ServiceValidationError(translation_domain=DOMAIN, translation_key="invalid_entry_id")
coordinator = entry.runtime_data.coordinator
@ -218,276 +72,3 @@ def has_tomorrow_data(coordinator: TibberPricesDataUpdateCoordinator) -> bool:
coordinator_data = coordinator.data or {}
tomorrow_intervals = get_intervals_for_day_offsets(coordinator_data, [1])
return len(tomorrow_intervals) > 0
def resolve_home_timezone(
coordinator: Any,
home_id: str,
) -> str:
"""Extract home timezone from coordinator's cached user data."""
user_data = coordinator._cached_user_data # noqa: SLF001
if not user_data:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="user_data_not_available",
)
if "viewer" in user_data:
for home in user_data["viewer"].get("homes", []):
if home.get("id") == home_id:
tz = home.get("timeZone")
if tz:
return tz
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="timezone_not_found",
)
def localize_to_home_tz(dt_value: datetime, home_tz: ZoneInfo) -> datetime:
"""
Localize a datetime to the Tibber home timezone.
Handles the critical two-step process:
1. GUI naive datetime localize to HA server timezone
2. Convert from HA timezone to home timezone
"""
if dt_value.tzinfo is None:
dt_value = dt_utils.as_local(dt_value)
return dt_value.astimezone(home_tz)
def calculate_end_of_tomorrow(home_tz: ZoneInfo) -> datetime:
"""Calculate end of tomorrow in home timezone."""
now_home = dt_utils.now().astimezone(home_tz)
tomorrow = (now_home + timedelta(days=1)).date()
# End of tomorrow = midnight at start of day after tomorrow
return now_home.replace(
year=tomorrow.year,
month=tomorrow.month,
day=tomorrow.day,
hour=0,
minute=0,
second=0,
microsecond=0,
) + timedelta(days=1)
def floor_to_quarter_hour(dt_value: datetime) -> datetime:
"""Floor a datetime to the current quarter-hour boundary."""
return dt_value.replace(minute=(dt_value.minute // INTERVAL_MINUTES) * INTERVAL_MINUTES, second=0, microsecond=0)
def _resolve_time_with_day_offset(
time_value: dt_time,
day_offset: int,
home_tz: ZoneInfo,
) -> datetime:
"""Resolve a time-of-day + day offset to a full datetime in home timezone."""
now_home = dt_utils.now().astimezone(home_tz)
target_date = (now_home + timedelta(days=day_offset)).date()
return datetime(
year=target_date.year,
month=target_date.month,
day=target_date.day,
hour=time_value.hour,
minute=time_value.minute,
second=time_value.second,
tzinfo=home_tz,
)
def _resolve_scope(scope: str, now: datetime, _home_tz: ZoneInfo) -> tuple[datetime, datetime]:
"""
Convert a search_scope shorthand into explicit start/end datetimes.
Args:
scope: One of "today", "tomorrow", "remaining_today", "next_24h", "next_48h"
now: Current datetime in home timezone
home_tz: Home timezone for date calculations
Returns:
Tuple of (start, end) datetimes in home timezone
"""
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
tomorrow_start = today_start + timedelta(days=1)
day_after_start = today_start + timedelta(days=2)
if scope == "today":
return today_start, tomorrow_start
if scope == "tomorrow":
return tomorrow_start, day_after_start
if scope == "remaining_today":
return floor_to_quarter_hour(now), tomorrow_start
if scope == "next_24h":
return floor_to_quarter_hour(now), now + timedelta(hours=24)
if scope == "next_48h":
return floor_to_quarter_hour(now), now + timedelta(hours=48)
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="invalid_search_scope",
)
def filter_intervals_by_price_level(
intervals: list[dict[str, Any]],
min_price_level: str | None,
max_price_level: str | None,
) -> list[dict[str, Any]]:
"""
Filter intervals by Tibber price level.
Keeps only intervals whose 'level' field is within the requested range.
If an interval has no 'level' field it is kept (avoids silently dropping data on API changes).
Args:
intervals: Price interval dicts with optional 'level' key
min_price_level: Lower bound level (inclusive), e.g. "CHEAP"
max_price_level: Upper bound level (inclusive), e.g. "NORMAL"
Returns:
Filtered list; same list reference if no filter is active
"""
if min_price_level is None and max_price_level is None:
return intervals
min_rank = _PRICE_LEVEL_RANK.get(min_price_level.upper(), 0) if min_price_level else 0
max_rank = (
_PRICE_LEVEL_RANK.get(max_price_level.upper(), len(PRICE_LEVEL_ORDER) - 1)
if max_price_level
else len(PRICE_LEVEL_ORDER) - 1
)
result = []
for iv in intervals:
level = iv.get("level")
if level is None:
result.append(iv)
continue
rank = _PRICE_LEVEL_RANK.get(str(level).upper())
if rank is None:
result.append(iv)
continue
if min_rank <= rank <= max_rank:
result.append(iv)
return result
def build_rating_lookup(coordinator_data: dict[str, Any]) -> dict[str, str | None]:
"""
Build a startsAt rating_level lookup from enriched coordinator data.
The coordinator's priceInfo contains rating_level (LOW/NORMAL/HIGH) computed
from trailing 24h averages with hysteresis. Pool intervals lack this field,
so this lookup allows annotating service responses with rating_level.
Args:
coordinator_data: coordinator.data dict with enriched priceInfo
Returns:
Dict mapping startsAt ISO string to lowercase rating_level (or None)
"""
lookup: dict[str, str | None] = {}
for iv in coordinator_data.get("priceInfo", []):
starts_at = iv.get("startsAt")
rating = iv.get("rating_level")
if starts_at:
lookup[starts_at] = rating.lower() if isinstance(rating, str) else None
return lookup
def build_response_interval(
iv: dict[str, Any],
unit_factor: int,
rating_lookup: dict[str, str | None],
) -> dict[str, Any]:
"""
Build an enriched interval dict for service responses.
Converts a raw pool interval into a companion-friendly format with
ends_at, level, and rating_level fields.
Args:
iv: Raw interval dict from pool (startsAt, total, level, ...)
unit_factor: Price unit multiplier (1 for base unit, 100 for cents, etc.)
rating_lookup: startsAt rating_level mapping from coordinator data
Returns:
Enriched interval dict for service response
"""
starts_at = iv["startsAt"]
if isinstance(starts_at, str):
ends_at = (datetime.fromisoformat(starts_at) + timedelta(minutes=INTERVAL_MINUTES)).isoformat()
else:
ends_at = (starts_at + timedelta(minutes=INTERVAL_MINUTES)).isoformat()
return {
"starts_at": starts_at,
"ends_at": ends_at,
"price": round(iv["total"] * unit_factor, 4),
"level": (iv.get("level") or "").lower() or None,
"rating_level": rating_lookup.get(starts_at),
}
def resolve_search_range(
call_data: dict[str, Any],
now: datetime,
home_tz: ZoneInfo,
) -> tuple[datetime, datetime]:
"""
Resolve search start/end from scope shorthand, explicit datetime, time+offset, or defaults.
Priority (highest to lowest):
0. search_scope shorthand (today, tomorrow, remaining_today, next_24h, next_48h)
1. Explicit datetime (search_start / search_end)
2. Time-of-day + day offset (search_start_time + search_start_day_offset)
3. Minutes offset (search_start_offset_minutes / search_end_offset_minutes)
4. Default (now for start, end of tomorrow for end)
Calls validate_search_params() first to check for conflicting combinations.
"""
validate_search_params(call_data)
include_current = call_data.get("include_current_interval", True)
# Priority 0: search_scope shorthand
if "search_scope" in call_data:
return _resolve_scope(call_data["search_scope"], now, home_tz)
# --- Resolve start ---
if "search_start" in call_data:
search_start = localize_to_home_tz(call_data["search_start"], home_tz)
elif "search_start_time" in call_data:
day_offset = call_data.get("search_start_day_offset", 0)
search_start = _resolve_time_with_day_offset(call_data["search_start_time"], day_offset, home_tz)
elif "search_start_offset_minutes" in call_data:
search_start = now + timedelta(minutes=call_data["search_start_offset_minutes"])
if include_current:
search_start = floor_to_quarter_hour(search_start)
else:
search_start = floor_to_quarter_hour(now) if include_current else now
# --- Resolve end ---
if "search_end" in call_data:
search_end = localize_to_home_tz(call_data["search_end"], home_tz)
elif "search_end_time" in call_data:
day_offset = call_data.get("search_end_day_offset", 0)
search_end = _resolve_time_with_day_offset(call_data["search_end_time"], day_offset, home_tz)
elif "search_end_offset_minutes" in call_data:
search_end = now + timedelta(minutes=call_data["search_end_offset_minutes"])
else:
search_end = calculate_end_of_tomorrow(home_tz)
if search_end <= search_start:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="end_before_start",
)
return search_start, search_end

View file

@ -40,7 +40,7 @@ ATTR_ENTRY_ID: Final = "entry_id"
# Service schema
REFRESH_USER_DATA_SERVICE_SCHEMA: Final = vol.Schema(
{
vol.Optional(ATTR_ENTRY_ID, default=""): str,
vol.Required(ATTR_ENTRY_ID): str,
}
)
@ -64,9 +64,15 @@ async def handle_refresh_user_data(call: ServiceCall) -> dict[str, Any]:
ServiceValidationError: If entry_id is missing or invalid
"""
entry_id = call.data.get(ATTR_ENTRY_ID, "")
entry_id = call.data.get(ATTR_ENTRY_ID)
hass = call.hass
if not entry_id:
return {
"success": False,
"message": "Entry ID is required",
}
# Get the entry and coordinator
try:
_, coordinator, _ = get_entry_and_data(hass, entry_id)

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

View file

@ -1,342 +0,0 @@
"""
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)

View file

@ -1,12 +1,12 @@
# Actions Overview
# Actions (Services)
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.
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.
Behind the scenes, YAML still uses the `service:` key — but the UI calls them "actions".
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.
## Finding Your Config Entry ID
## Finding Your Entry ID
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.
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.
### In the Action UI — no lookup needed
@ -25,43 +25,349 @@ When you write YAML directly (automations, scripts, Lovelace dashboard cards), y
The ID looks like a long alphanumeric string, for example `01JKPC7AB3EF4GH5IJ6KL7MN8P`.
:::tip Multiple homes?
If you have configured more than one Tibber home, each home has its own config 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 entry ID. Repeat the steps above for each integration card to get the individual IDs.
:::
## All Actions at a Glance
## Available Actions
### Scheduling Actions
### tibber_prices.get_chartdata
Find the cheapest (or most expensive) time windows for your appliances. Ideal for automating when to run devices based on real price data.
**Purpose:** Returns electricity price data in chart-friendly formats for visualization and analysis.
| 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 |
**Key Features:**
**→ [Scheduling Actions — Full Guide](scheduling-actions.md)** with parameters, response formats, decision flowchart, and automation examples.
- **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)
### Chart & Visualization Actions
**Basic Example:**
Generate chart-ready data and ApexCharts configurations for your dashboards.
```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
```
| 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 |
**Response Format:**
**→ [Chart & Visualization Actions — Full Guide](chart-actions.md)** with parameters, examples, rolling window modes, and migration guide.
```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
}
]
}
```
### Data & Utility Actions
**Common Parameters:**
Fetch raw price data or refresh cached information.
| Parameter | Description | Default |
| ---------------- | ------------------------------------------- | ----------------------- |
| `entry_id` | Integration entry ID (required) | - |
| `day` | Days to include: yesterday, today, tomorrow | `["today", "tomorrow"]` |
| `output_format` | `array_of_objects` or `array_of_arrays` | `array_of_objects` |
| `resolution` | `interval` (15-min) or `hourly` | `interval` |
| `subunit_currency` | Override display mode: `true` for subunit (ct/øre), `false` for base (€/kr) | Integration setting |
| `round_decimals` | Decimal places (0-10) | 2 (subunit) or 4 (base) |
| 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 |
**Rolling Window Mode:**
**→ [Data & Utility Actions — Full Guide](data-actions.md)** with parameters and response formats.
Omit the `day` parameter to get a dynamic 48-hour rolling window that automatically adapts to data availability:
```yaml
service: tibber_prices.get_chartdata
data:
entry_id: YOUR_CONFIG_ENTRY_ID
# Omit 'day' for rolling window
output_format: array_of_objects
response_variable: chart_data
```
**Behavior:**
- **When tomorrow data available** (typically after ~13:00): Returns today + tomorrow
- **When tomorrow data not available**: Returns yesterday + today
This is useful for charts that should always show a 48-hour window without manual day selection.
**Period Filter Example:**
Get best price periods as summaries instead of intervals:
```yaml
service: tibber_prices.get_chartdata
data:
entry_id: YOUR_CONFIG_ENTRY_ID
period_filter: best_price # or peak_price
day: ["today", "tomorrow"]
include_level: true
include_rating_level: true
response_variable: periods
```
**Advanced Filtering:**
```yaml
service: tibber_prices.get_chartdata
data:
entry_id: YOUR_CONFIG_ENTRY_ID
level_filter: ["VERY_CHEAP", "CHEAP"] # Only cheap periods
rating_level_filter: ["LOW"] # Only low-rated prices
insert_nulls: segments # Add nulls at segment boundaries
```
**Complete Documentation:**
For detailed parameter descriptions, open **Developer Tools → Actions** (the UI label) and select `tibber_prices.get_chartdata`. The inline documentation is still stored in `services.yaml` because actions are backed by services.
#### Energy & Tax Fields in get_chartdata
You can include the raw energy price (spot price) and/or tax component in chart data output. This is useful for visualizing how the total price is composed over time, or for feed-in calculations.
| Parameter | Description | Default |
|-----------|-------------|---------|
| `include_energy` | Include raw energy/spot price per data point | `false` |
| `include_tax` | Include tax/fees component per data point | `false` |
| `energy_field` | Custom field name for energy price | `energy_price` |
| `tax_field` | Custom field name for tax | `tax` |
**Example: Chart with price composition**
```yaml
service: tibber_prices.get_chartdata
data:
entry_id: YOUR_CONFIG_ENTRY_ID
day: ["today", "tomorrow"]
include_energy: true
include_tax: true
response_variable: chart_data
```
Returns data points like:
```json
{
"start_time": "2025-11-17T14:00:00+01:00",
"price_per_kwh": 25.34,
"energy_price": 12.18,
"tax": 13.16
}
```
**Use case — Solar feed-in chart:** Overlay the energy price (what you earn by exporting) alongside the total price to visualize the best export windows. See [Energy & Tax Attributes](sensors-energy-tax.md) for more use cases.
---
### tibber_prices.get_apexcharts_yaml
> ⚠️ **IMPORTANT:** This action generates a **basic example configuration** as a starting point, NOT a complete solution for all ApexCharts features.
>
> This integration is primarily a **data provider**. The generated YAML demonstrates how to use the `get_chartdata` action to fetch price data. Due to the segmented nature of our data (different time periods per series) and the use of Home Assistant's service API instead of entity attributes, many advanced ApexCharts features (like `in_header`, certain transformations) are **not compatible** or require manual customization.
>
> **You are welcome to customize** the generated YAML for your specific needs, but comprehensive ApexCharts configuration support is beyond the scope of this integration. Community contributions with improved configurations are always appreciated!
>
> **For custom solutions:** Use the `get_chartdata` action directly to build your own charts with full control over the data format and visualization.
**Purpose:** Generates a basic ApexCharts card YAML configuration example for visualizing electricity prices with automatic color-coding by price level.
**Prerequisites:**
- [ApexCharts Card](https://github.com/RomRider/apexcharts-card) (required for all configurations)
- [Config Template Card](https://github.com/iantrich/config-template-card) (required only for rolling window modes - enables dynamic Y-axis scaling)
**✨ Key Features:**
- **Automatic Color-Coded Series**: Separate series for each price level (VERY_CHEAP, CHEAP, NORMAL, EXPENSIVE, VERY_EXPENSIVE) or rating (LOW, NORMAL, HIGH)
- **Dynamic Y-Axis Scaling**: Rolling window modes automatically use `chart_metadata` sensor for optimal Y-axis bounds
- **Best Price Period Highlights**: Optional vertical bands showing detected best price periods
- **Translated Labels**: Automatically uses your Home Assistant language setting
- **Clean Gap Visualization**: Proper NULL insertion for missing data segments
**Quick Example:**
```yaml
service: tibber_prices.get_apexcharts_yaml
data:
entry_id: YOUR_CONFIG_ENTRY_ID
day: today # Optional: yesterday, today, tomorrow, rolling_window, rolling_window_autozoom
level_type: rating_level # or "level" for 5-level classification
highlight_best_price: true # Show best price period overlays
response_variable: apexcharts_config
```
**Day Parameter Options:**
- **Fixed days** (`yesterday`, `today`, `tomorrow`): Static 24-hour views, no additional dependencies
- **Rolling Window** (default when omitted or `rolling_window`): Dynamic 48-hour window that automatically shifts between yesterday+today and today+tomorrow based on data availability
- **✨ Includes dynamic Y-axis scaling** via `chart_metadata` sensor
- **Rolling Window (Auto-Zoom)** (`rolling_window_autozoom`): Same as rolling window, but additionally zooms in progressively (2h lookback + remaining time until midnight, graph span decreases every 15 minutes)
- **✨ Includes dynamic Y-axis scaling** via `chart_metadata` sensor
**Dynamic Y-Axis Scaling (Rolling Window Modes):**
Rolling window configurations automatically integrate with the `chart_metadata` sensor for optimal chart appearance:
- **Automatic bounds**: Y-axis min/max adjust to data range
- **No manual configuration**: Works out of the box if sensor is enabled
- **Fallback behavior**: If sensor is disabled, uses ApexCharts auto-scaling
- **Real-time updates**: Y-axis adapts when price data changes
**Example: Today's Prices (Static View)**
```yaml
service: tibber_prices.get_apexcharts_yaml
data:
entry_id: YOUR_CONFIG_ENTRY_ID
day: today
level_type: rating_level
response_variable: config
# Use in dashboard:
type: custom:apexcharts-card
# ... paste generated config
```
**Example: Rolling 48h Window (Dynamic View)**
```yaml
service: tibber_prices.get_apexcharts_yaml
data:
entry_id: YOUR_CONFIG_ENTRY_ID
# Omit 'day' for rolling window (or use 'rolling_window')
level_type: level # 5-level classification
highlight_best_price: true
response_variable: config
# Use in dashboard:
type: custom:config-template-card
entities:
- binary_sensor.<home_name>_tomorrow_s_data_available
- sensor.<home_name>_chart_metadata # For dynamic Y-axis
card:
# ... paste generated config
```
**Screenshots:**
_Screenshots coming soon for all 4 modes: today, tomorrow, rolling_window, rolling_window_autozoom_
**Level Type Options:**
- **`rating_level`** (default): 3 series (LOW, NORMAL, HIGH) - based on your personal thresholds
- **`level`**: 5 series (VERY_CHEAP, CHEAP, NORMAL, EXPENSIVE, VERY_EXPENSIVE) - absolute price ranges
**Best Price Period Highlights:**
When `highlight_best_price: true`:
- Vertical bands overlay the chart showing detected best price periods
- Tooltip shows "Best Price Period" label when hovering over highlighted areas
- Only appears when best price periods are configured and detected
**Important Notes:**
- **Config Template Card** is only required for rolling window modes (enables dynamic Y-axis)
- Fixed day views (`today`, `tomorrow`, `yesterday`) work with ApexCharts Card alone
- Generated YAML is a starting point - customize colors, styling, features as needed
- All labels are automatically translated to your Home Assistant language
Use the response in Lovelace dashboards by copying the generated YAML.
**Documentation:** Refer to **Developer Tools → Actions** for descriptions of the fields exposed by this action.
---
### tibber_prices.refresh_user_data
**Purpose:** Forces an immediate refresh of user data (homes, subscriptions) from the Tibber API.
**Example:**
```yaml
service: tibber_prices.refresh_user_data
data:
entry_id: YOUR_CONFIG_ENTRY_ID
```
**Note:** User data is cached for 24 hours. Trigger this action only when you need immediate updates (e.g., after changing Tibber subscriptions).
---
### tibber_prices.get_price
**Purpose:** Fetches raw price interval data for any time range. Uses intelligent caching — only intervals not already cached are fetched from the Tibber API.
**Parameters:**
| Parameter | Description | Required |
|-----------|-------------|----------|
| `entry_id` | Integration entry ID | Yes |
| `start_time` | Start of the time range | Yes |
| `end_time` | End of the time range | Yes |
**Example:**
```yaml
service: tibber_prices.get_price
data:
entry_id: YOUR_CONFIG_ENTRY_ID
start_time: "2025-11-01T00:00:00"
end_time: "2025-11-02T00:00:00"
response_variable: price_data
```
**Response Format:**
```json
{
"home_id": "abc-123",
"start_time": "2025-11-01T00:00:00+01:00",
"end_time": "2025-11-02T00:00:00+01:00",
"interval_count": 96,
"price_info": [
{
"startsAt": "2025-11-01T00:00:00+01:00",
"total": 0.2534,
"energy": 0.1218,
"tax": 0.1316
}
]
}
```
**Use cases:**
- Fetching historical price data for analysis
- Comparing prices across arbitrary date ranges
- Building custom charts with historical data
**Note:** Times are automatically converted to your Tibber home's timezone. The interval pool caches previously fetched intervals, so repeated calls for the same range are fast.
---
## Migration from Chart Data Export Sensor
If you're still using the `sensor.<home_name>_chart_data_export` sensor, consider migrating to the `tibber_prices.get_chartdata` action:
**Benefits:**
- No HA restart required for configuration changes
- More flexible filtering and formatting options
- Better performance (on-demand instead of polling)
- Future-proof (active development)
**Migration Steps:**
1. Note your current sensor configuration (Step 7 in Options Flow)
2. Create automation/script that calls `tibber_prices.get_chartdata` with the same parameters
3. Test the new approach
4. Disable the old sensor when satisfied

View file

@ -7,7 +7,6 @@
- [Price-Based Automations](#price-based-automations)
- [Volatility-Aware Automations](#volatility-aware-automations)
- [Best Hour Detection](#best-hour-detection)
- [Scheduling Actions](#scheduling-actions)
- [Charts & Visualizations](#charts--visualizations)
---
@ -20,9 +19,7 @@
>
> These examples provide a good starting point but must be tailored to your individual Home Assistant setup.
>
:::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.
:::
> **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
@ -62,7 +59,7 @@ gantt
This automation starts a flexible load when the best price period begins, but keeps it running as long as prices remain favorable — even after the period ends.
<details>
<summary>Show YAML: Ride the Full Cheap Wave</summary>
<summary>Show YAML: Heat pump — extended cheap period</summary>
```yaml
automation:
@ -123,7 +120,7 @@ automation:
Use the trend to start slightly before the cheapest period — useful for appliances with warm-up time:
<details>
<summary>Show YAML: Pre-Emptive Start</summary>
<summary>Show YAML: Water heater — pre-heat before cheapest window</summary>
```yaml
automation:
@ -159,7 +156,7 @@ automation:
Stop or reduce consumption when prices are climbing:
<details>
<summary>Show YAML: Protect Against Rising Prices</summary>
<summary>Show YAML: EV charger — stop when prices rising</summary>
```yaml
automation:
@ -199,7 +196,7 @@ Combine short-term and long-term trend sensors for smarter decisions. This examp
- If long-term says `falling` → cheaper hours ahead, no rush
<details>
<summary>Show YAML: Multi-Window Trend Strategy</summary>
<summary>Show YAML: Heat pump — multi-window trend strategy</summary>
```yaml
automation:
@ -299,7 +296,7 @@ On days with low price variation, the difference between "cheap" and "expensive"
**Best Practice:** Instead of checking a numeric percentage, this automation checks the sensor's classified state. This makes the automation simpler and respects the volatility thresholds you have configured centrally in the integration's options.
<details>
<summary>Show YAML: Meaningful Price Variations</summary>
<summary>Show YAML: Home battery — charge on best price (volatility-aware)</summary>
```yaml
automation:
@ -346,7 +343,7 @@ automation:
This is the most robust approach. It trusts the "Best Price" classification on volatile days but adds a backup absolute price check for low-volatility days. This handles situations where prices are globally low, even if the daily variation is minimal.
<details>
<summary>Show YAML: Volatility and Absolute Price Check</summary>
<summary>Show YAML: EV charging — combined volatility + absolute price strategy</summary>
```yaml
automation:
@ -396,7 +393,7 @@ automation:
For maximum simplicity, you can use the attributes of the `best_price_period` sensor itself. It contains the volatility classification for the day the period belongs to. This is especially useful for periods that span across midnight.
<details>
<summary>Show YAML: Period Volatility Attribute</summary>
<summary>Show YAML: Heat pump — smart heating using period volatility attribute</summary>
```yaml
automation:
@ -437,7 +434,7 @@ automation:
Use future average sensors to determine the cheapest upcoming window for a timed appliance (e.g., dishwasher with 2-hour ECO program):
<details>
<summary>Show YAML: Best Time for an Appliance</summary>
<summary>Show YAML: Dishwasher — schedule for cheapest 2h window</summary>
```yaml
automation:
@ -479,7 +476,7 @@ automation:
Get a push notification when the best price period begins:
<details>
<summary>Show YAML: Cheap Window Notification</summary>
<summary>Show YAML: Notification — when cheap window starts</summary>
```yaml
automation:
@ -505,12 +502,6 @@ automation:
---
## Scheduling Actions
> **Looking for scheduling actions?** The **[Scheduling Actions Guide](scheduling-actions.md)** covers `find_cheapest_block`, `find_cheapest_hours`, `find_cheapest_schedule`, and their "most expensive" counterparts — ideal for automations that need to find optimal time windows dynamically (e.g., EV charging, heat pump scheduling, appliance timing).
---
## Charts & Visualizations
> **Looking for chart configurations?** See the **[Chart Examples Guide](chart-examples.md)** for ApexCharts card configurations, rolling window modes, and more.

View file

@ -1,330 +0,0 @@
# 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

View file

@ -4,9 +4,7 @@ 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!
:::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.
:::
> **Entity ID tip:** `<home_name>` is a placeholder for your Tibber home display name in Home Assistant. Entity IDs are derived from the displayed name (localized), so the exact slug may differ. **Can't find a sensor?** Use the **[Entity Reference (All Languages)](sensor-reference.md)** to search by name in your language.
:::info Finding your Entry ID (`entry_id`)
Every example below contains `entry_id: YOUR_CONFIG_ENTRY_ID`. This value identifies which Tibber home (integration instance) the action targets.
@ -41,9 +39,6 @@ The integration can generate 4 different chart modes, each optimized for specifi
**Dependencies:** ApexCharts Card only
**Generate:**
<details>
<summary>Show YAML: Today's Prices (Static)</summary>
```yaml
service: tibber_prices.get_apexcharts_yaml
data:
@ -53,8 +48,6 @@ data:
highlight_best_price: true
```
</details>
**Screenshot:**
![Today's Prices - Static 24h View](/img/charts/today.jpg)
@ -76,9 +69,6 @@ data:
**Dependencies:** ApexCharts Card + Config Template Card
**Generate:**
<details>
<summary>Show YAML: Rolling 48h Window</summary>
```yaml
service: tibber_prices.get_apexcharts_yaml
data:
@ -88,8 +78,6 @@ data:
highlight_best_price: true
```
</details>
**Screenshot:**
![Rolling 48h Window with Dynamic Y-Axis Scaling](/img/charts/rolling-window.jpg)
@ -115,9 +103,6 @@ data:
**Dependencies:** ApexCharts Card + Config Template Card
**Generate:**
<details>
<summary>Show YAML: Rolling Auto-Zoom</summary>
```yaml
service: tibber_prices.get_apexcharts_yaml
data:
@ -127,8 +112,6 @@ data:
highlight_best_price: true
```
</details>
**Screenshot:**
![Rolling Window Auto-Zoom - Progressive Zoom Effect](/img/charts/rolling-window-autozoom.jpg)
@ -182,9 +165,6 @@ Based on **absolute price ranges** (calculated from daily min/max):
Rolling window modes (2 & 3) automatically integrate with the `chart_metadata` sensor for optimal visualization:
**Without chart_metadata sensor (disabled):**
<details>
<summary>Show chart illustration: Without Chart Metadata</summary>
```
┌─────────────────────┐
│ │ ← Lots of empty space
@ -195,12 +175,7 @@ Rolling window modes (2 & 3) automatically integrate with the `chart_metadata` s
0 100 ct
```
</details>
**With chart_metadata sensor (enabled):**
<details>
<summary>Show chart illustration: With Chart Metadata</summary>
```
┌─────────────────────┐
│ ___ │ ← Y-axis fitted to data
@ -210,8 +185,6 @@ Rolling window modes (2 & 3) automatically integrate with the `chart_metadata` s
18 28 ct ← Optimal range
```
</details>
**Requirements:**
- ✅ The `sensor.<home_name>_chart_metadata` must be **enabled** (it's enabled by default!)
@ -228,9 +201,6 @@ Rolling window modes (2 & 3) automatically integrate with the `chart_metadata` s
When `highlight_best_price: true`, vertical bands overlay the chart showing detected best price periods:
**Example:**
<details>
<summary>Show chart illustration: Best Price Period Highlights</summary>
```
Price
@ -244,8 +214,6 @@ Price
06:00 12:00 18:00
```
</details>
**Features:**
- Automatic detection based on your configuration (see [Period Calculation Guide](period-calculation.md))
- Tooltip shows "Best Price Period" label
@ -259,29 +227,19 @@ Price
### Required for All Modes
- **[ApexCharts Card](https://github.com/RomRider/apexcharts-card)**: Core visualization library
<details>
<summary>Show shell command: Prerequisite for All Modes</summary>
```bash
# Install via HACS
HACS → Frontend → Search "ApexCharts Card" → Download
```
</details>
### Required for Rolling Window Modes Only
- **[Config Template Card](https://github.com/iantrich/config-template-card)**: Enables dynamic configuration
<details>
<summary>Show shell command: Rolling Window Prerequisite</summary>
```bash
# Install via HACS
HACS → Frontend → Search "Config Template Card" → Download
```
</details>
**Note:** Fixed day views (`today`, `tomorrow`) work with ApexCharts Card alone!
---
@ -292,9 +250,6 @@ Price
Edit the `colors` array in the generated YAML:
<details>
<summary>Show YAML: Custom Color Palette</summary>
```yaml
apex_config:
colors:
@ -303,15 +258,10 @@ apex_config:
- "#FF0000" # Change HIGH/VERY_EXPENSIVE color
```
</details>
### Changing Chart Height
Add to the card configuration:
<details>
<summary>Show YAML: Custom Chart Height</summary>
```yaml
type: custom:apexcharts-card
graph_span: 48h
@ -323,15 +273,10 @@ apex_config:
height: 400 # Adjust height in pixels
```
</details>
### Combining with Other Cards
Wrap in a vertical stack for dashboard integration:
<details>
<summary>Show YAML: Vertical Stack Integration</summary>
```yaml
type: vertical-stack
cards:
@ -341,13 +286,11 @@ cards:
# ... generated chart config
```
</details>
---
## Next Steps
- **[Chart Actions Guide](chart-actions.md)**: Complete documentation of `get_apexcharts_yaml` parameters
- **[Actions Guide](actions.md)**: Complete documentation of `get_apexcharts_yaml` parameters
- **[Chart Metadata Sensor](sensors-overview.md#chart-metadata)**: Learn about dynamic Y-axis scaling
- **[Period Calculation Guide](period-calculation.md)**: Configure best price period detection

View file

@ -62,7 +62,7 @@ Create `input_number` helpers in Home Assistant for each fee component. This way
| Verkoopvergoeding | `input_number.verkoopvergoeding` | 0 | 1 | 0.0001 | €/kWh | 0.0205 |
<details>
<summary>Show YAML: Input Number Helpers</summary>
<summary>Alternative: YAML configuration for input_number helpers</summary>
If you prefer YAML configuration over the UI, add these to your `configuration.yaml`:
@ -105,7 +105,7 @@ input_number:
These template sensors calculate what you **earn** per kWh when feeding solar power back to the grid.
<details>
<summary>Show YAML: Feed-In Compensation Sensors</summary>
<summary>Show YAML: Template sensors for feed-in with and without saldering</summary>
```yaml
template:
@ -156,7 +156,7 @@ template:
Now you can use these sensors to make smarter decisions about when to export solar power vs. charge a battery:
<details>
<summary>Show YAML: Feed-In Automation</summary>
<summary>Show YAML: Smart export automation</summary>
```yaml
automation:
@ -188,7 +188,7 @@ automation:
To understand the financial impact of the saldering phase-out, you can create a dashboard comparing both scenarios side by side:
<details>
<summary>Show YAML: Preparing for the End of Saldering</summary>
<summary>Show YAML: Dashboard comparison card</summary>
```yaml
type: entities
@ -233,7 +233,7 @@ In Germany, the electricity price includes numerous components bundled into `tax
A simple template sensor showing what percentage of your total price is the actual energy cost:
<details>
<summary>Show YAML: Spot Price Share</summary>
<summary>Show YAML: Spot price share template sensor</summary>
```yaml
template:

View file

@ -1,8 +1,6 @@
# Configuration
:::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.
:::
> **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
@ -127,7 +125,7 @@ Thresholds are [volatility-adaptive](sensors-trends.md): automatically widened o
### Step 9: Chart Data Export (Legacy)
Information page for the legacy chart data export sensor. For new setups, use the [get_chartdata action](chart-actions.md) instead.
Information page for the legacy chart data export sensor. For new setups, use the [get_chartdata action](actions.md) instead.
## Configuration Options
@ -154,15 +152,10 @@ The integration allows you to choose how average price sensors display their val
#### Why This Matters
Consider a day with these hourly prices:
<details>
<summary>Show example: Mean vs Median</summary>
```
10, 12, 13, 15, 80 ct/kWh
```
</details>
- **Median = 13 ct/kWh** ← "Typical" price (middle value, ignores spike)
- **Mean = 26 ct/kWh** ← Average cost (spike pulls it up)
@ -172,17 +165,12 @@ The median tells you the price was **typically** around 13 ct/kWh (4 out of 5 ho
**Both values are always available as attributes**, regardless of your display choice:
<details>
<summary>Show YAML: Median and Mean Attributes</summary>
```yaml
# These attributes work regardless of display setting:
{{ state_attr('sensor.<home_name>_price_today', 'price_median') }}
{{ state_attr('sensor.<home_name>_price_today', 'price_mean') }}
```
</details>
This means:
- ✅ You can change the display anytime without breaking automations
- ✅ Automations can use both values for different purposes
@ -267,9 +255,6 @@ Each configuration entity includes a detailed description attribute explaining w
### Example: Seasonal Automation
<details>
<summary>Show YAML: Seasonal Runtime Override</summary>
```yaml
automation:
- alias: "Winter: Stricter Best Price Detection"
@ -287,8 +272,6 @@ automation:
value: 10 # Stricter than default 15%
```
</details>
### Recorder Optimization (Optional)
These configuration entities are designed to minimize database impact:
@ -300,9 +283,6 @@ If you frequently adjust these settings via automations or want to track configu
However, if you prefer to **completely exclude** these entities from the recorder (no history graph, no database entries), add this to your `configuration.yaml`:
<details>
<summary>Show YAML: Exclude Runtime Config Entities</summary>
```yaml
recorder:
exclude:
@ -314,8 +294,6 @@ recorder:
- switch.*_peak_price_*
```
</details>
This is especially useful if:
- You rarely change these settings
- You want the smallest possible database footprint

View file

@ -2,17 +2,12 @@
Beautiful dashboard layouts using Tibber Prices sensors.
:::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.
:::
> **Entity ID tip:** `<home_name>` is a placeholder for your Tibber home display name in Home Assistant. Entity IDs are derived from the displayed name (localized), so the exact slug may differ. **Can't find a sensor?** Use the **[Entity Reference (All Languages)](sensor-reference.md)** to search by name in your language.
## Basic Price Display Card
Simple card showing current price with dynamic color:
<details>
<summary>Show YAML: Current Price Card</summary>
```yaml
type: entities
title: Current Electricity Price
@ -26,15 +21,10 @@ entities:
name: Next Price
```
</details>
## Period Status Cards
Show when best/peak price periods are active:
<details>
<summary>Show YAML: Best and Peak Period Card</summary>
```yaml
type: horizontal-stack
cards:
@ -48,14 +38,12 @@ cards:
icon: mdi:alert
```
</details>
## Custom Button Card Examples
### Price Level Card
<details>
<summary>Show YAML: Price Level Card</summary>
<summary>Show YAML: Price level card with color gradients (custom:button-card)</summary>
```yaml
type: custom:button-card
@ -84,7 +72,7 @@ styles:
Optimized for mobile devices:
<details>
<summary>Show YAML: Optimized for mobile devices</summary>
<summary>Show YAML: Compact mobile layout</summary>
```yaml
type: vertical-stack
@ -111,7 +99,7 @@ cards:
Full-width layout for desktop:
<details>
<summary>Show YAML: Full-width layout for desktop</summary>
<summary>Show YAML: Desktop 3-column grid layout</summary>
```yaml
type: grid
@ -146,7 +134,7 @@ cards:
Using the `icon_color` attribute for dynamic colors:
<details>
<summary>Show YAML: Icon Color Integration</summary>
<summary>Show YAML: Dynamic icon colors with mushroom chips</summary>
```yaml
type: custom:mushroom-chips-card
@ -173,7 +161,7 @@ See [Icon Colors](icon-colors.md) for detailed color mapping.
Advanced interactive dashboard:
<details>
<summary>Show YAML: Advanced interactive dashboard</summary>
<summary>Show YAML: Picture elements interactive dashboard</summary>
```yaml
type: picture-elements
@ -203,7 +191,7 @@ elements:
Automatically list all price sensors:
<details>
<summary>Show YAML: Auto-Entities Sensor List</summary>
<summary>Show YAML: Auto-entities card for all price sensors</summary>
```yaml
type: custom:auto-entities

View file

@ -1,85 +0,0 @@
# 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).

View file

@ -2,9 +2,7 @@
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.
:::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.
:::
> **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?
@ -39,9 +37,6 @@ To see which icon a sensor currently uses:
Dynamic icons work automatically in standard Home Assistant cards:
<details>
<summary>Show YAML: Standard Entity Cards</summary>
```yaml
type: entities
entities:
@ -51,15 +46,10 @@ entities:
- entity: binary_sensor.<home_name>_best_price_period
```
</details>
The icons will update automatically as the sensor states change.
### Glance Card
<details>
<summary>Show YAML: Glance Card</summary>
```yaml
type: glance
entities:
@ -71,13 +61,8 @@ entities:
name: Best Price
```
</details>
### Custom Button Card
<details>
<summary>Show YAML: Custom Button Card</summary>
```yaml
type: custom:button-card
entity: sensor.<home_name>_current_price_level
@ -86,13 +71,8 @@ show_state: true
# Icon updates automatically - no need to specify it!
```
</details>
### Mushroom Entity Card
<details>
<summary>Show YAML: Mushroom Entity Card</summary>
```yaml
type: custom:mushroom-entity-card
entity: sensor.<home_name>_today_s_price_volatility
@ -100,17 +80,12 @@ name: Price Volatility
# Icon changes automatically based on volatility level
```
</details>
## Overriding Dynamic Icons
If you want to use a fixed icon instead of the dynamic one:
### In Entity Cards
<details>
<summary>Show YAML: Fixed Icon in Entity Card</summary>
```yaml
type: entities
entities:
@ -118,13 +93,8 @@ entities:
icon: mdi:lightning-bolt # Fixed icon, won't change
```
</details>
### In Custom Button Card
<details>
<summary>Show YAML: Fixed Icon in Button Card</summary>
```yaml
type: custom:button-card
entity: sensor.<home_name>_current_price_rating
@ -133,17 +103,12 @@ icon: mdi:chart-line # Fixed icon overrides dynamic behavior
show_state: true
```
</details>
## Combining with Dynamic Colors
Dynamic icons work great together with dynamic colors! See the **[Dynamic Icon Colors Guide](icon-colors.md)** for examples.
**Example: Dynamic icon AND color**
<details>
<summary>Show YAML: Dynamic Icon and Color</summary>
```yaml
type: custom:button-card
entity: sensor.<home_name>_current_price_level
@ -158,8 +123,6 @@ styles:
]]]
```
</details>
This gives you both:
- ✅ Different icon based on state (e.g., cash-plus when cheap, cash-remove when expensive)

View file

@ -112,15 +112,10 @@ If you see unexpected units, check your configuration in the integration options
## Automation Questions
:::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.
:::
> **Entity ID tip:** `<home_name>` is a placeholder for your Tibber home display name in Home Assistant. Entity IDs are derived from the displayed name (localized), so the exact slug may differ. **Can't find a sensor?** Use the **[Entity Reference (All Languages)](sensor-reference.md)** to search by name in your language.
### How do I run dishwasher during cheap period?
<details>
<summary>Show YAML: Dishwasher During Cheap Period</summary>
```yaml
automation:
- alias: "Dishwasher during Best Price"
@ -137,17 +132,12 @@ automation:
entity_id: switch.dishwasher
```
</details>
See [Automation Examples](automation-examples.md) for more recipes.
### Can I avoid peak prices automatically?
Yes! Use Peak Price Period binary sensor:
<details>
<summary>Show YAML: Avoid Peak Prices Automatically</summary>
```yaml
automation:
- alias: "Disable charging during peak prices"
@ -161,8 +151,6 @@ automation:
entity_id: switch.ev_charger
```
</details>
---
💡 **Still need help?**

View file

@ -10,9 +10,7 @@ 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.
:::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.
:::
> **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?
@ -67,9 +65,6 @@ The colors adapt to the sensor's state - cheaper prices typically show green, ex
**Example of when NOT to use icon_color:**
<details>
<summary>Show YAML: Conversion vs Direct State Logic</summary>
```yaml
# ❌ DON'T: Converting icon_color requires if/else anyway
card:
@ -92,8 +87,6 @@ card:
]]]
```
</details>
The advantage of `icon_color` is simplicity - if you need complex logic, you lose that advantage.
## How to Use icon_color in Your Dashboard
@ -104,9 +97,6 @@ The [custom:button-card](https://github.com/custom-cards/button-card) from HACS
**Example: Icon color only**
<details>
<summary>Show YAML: Icon color only</summary>
```yaml
type: custom:button-card
entity: sensor.<home_name>_current_price_level
@ -121,13 +111,8 @@ styles:
]]]
```
</details>
**Example: Icon AND state value with same color**
<details>
<summary>Show YAML: Button Card Icon and State Text</summary>
```yaml
type: custom:button-card
entity: sensor.<home_name>_current_price_level
@ -148,14 +133,12 @@ styles:
- font-weight: bold
```
</details>
### Method 2: Entities Card with card_mod
Use Home Assistant's built-in entities card with card_mod for icon and state colors:
<details>
<summary>Show YAML: Entities Card with card-mod</summary>
<summary>Show YAML: Entities card with card_mod</summary>
```yaml
type: entities
@ -181,9 +164,6 @@ The [Mushroom cards](https://github.com/piitaya/lovelace-mushroom) support card_
**Icon color only:**
<details>
<summary>Show YAML: Mushroom Icon Color Only</summary>
```yaml
type: custom:mushroom-entity-card
entity: binary_sensor.<home_name>_best_price_period
@ -196,12 +176,10 @@ card_mod:
}
```
</details>
**Icon and state value:**
<details>
<summary>Show YAML: Icon and state value</summary>
<summary>Show YAML: Mushroom card — icon and state value with same color</summary>
```yaml
type: custom:mushroom-entity-card
@ -222,7 +200,7 @@ card_mod:
Combine multiple sensors with dynamic colors:
<details>
<summary>Show YAML: Multi-Sensor Dynamic Colors</summary>
<summary>Show YAML: Glance card with card_mod for multiple sensors</summary>
```yaml
type: glance
@ -250,7 +228,7 @@ card_mod:
Here's a complete example combining multiple sensors with dynamic colors:
<details>
<summary>Show YAML: Complete Dashboard Example</summary>
<summary>Show YAML: Complete dashboard with dynamic colors for all sensor types</summary>
```yaml
type: vertical-stack
@ -356,9 +334,6 @@ If you want to override the theme colors with your own, you have two options:
Define custom colors in your theme configuration (`themes.yaml`):
<details>
<summary>Show YAML: Theme-Level Color Overrides</summary>
```yaml
my_custom_theme:
# Override standard variables
@ -368,8 +343,6 @@ my_custom_theme:
info-color: "#0288D1" # Custom blue
```
</details>
The `icon_color` attribute will automatically use your custom theme colors.
#### Option 2: Interpret State Value Directly
@ -379,7 +352,7 @@ Instead of using `icon_color`, read the sensor state and apply your own colors:
**Example: Custom colors for price level**
<details>
<summary>Show YAML: Custom colors for price level</summary>
<summary>Show YAML: Price level — 5-color JavaScript mapping</summary>
```yaml
type: custom:button-card
@ -406,7 +379,7 @@ styles:
**Example: Custom colors for binary sensor**
<details>
<summary>Show YAML: Custom colors for binary sensor</summary>
<summary>Show YAML: Binary sensor — on/off color + background highlight</summary>
```yaml
type: custom:button-card
@ -433,7 +406,7 @@ styles:
**Example: Custom colors for volatility**
<details>
<summary>Show YAML: Custom colors for volatility</summary>
<summary>Show YAML: Volatility sensor — 4-level color mapping</summary>
```yaml
type: custom:button-card
@ -458,7 +431,7 @@ styles:
**Example: Custom colors for price rating**
<details>
<summary>Show YAML: Custom colors for price rating</summary>
<summary>Show YAML: Price rating — 3-state color mapping</summary>
```yaml
type: custom:button-card

View file

@ -16,14 +16,9 @@
2. Go to **Integrations**
3. Click the **⋮** menu (top right) → **Custom repositories**
4. Add the repository URL:
<details>
<summary>Show example: Repository URL</summary>
```
https://github.com/jpawlowski/hass.tibber_prices
```
</details>
Category: **Integration**
5. Click **Add**
6. Find **Tibber Price Information & Ratings** in the integration list
@ -47,9 +42,6 @@ If you prefer not to use HACS:
1. Download the [latest release](https://github.com/jpawlowski/hass.tibber_prices/releases/latest) from GitHub
2. Extract the `custom_components/tibber_prices/` folder
3. Copy it to your Home Assistant `config/custom_components/` directory:
<details>
<summary>Show example: Folder Structure</summary>
```
config/
└── custom_components/
@ -60,8 +52,6 @@ If you prefer not to use HACS:
├── binary_sensor/
└── ...
```
</details>
4. **Restart Home Assistant**
5. Continue with [Configuration](configuration.md)

View file

@ -18,7 +18,7 @@ This is an independent, community-maintained custom integration. It is **not** a
- **[Sensors](sensors-overview.md)** - Available sensors, their states, and attributes
- **[Dynamic Icons](dynamic-icons.md)** - State-based automatic icon changes
- **[Dynamic Icon Colors](icon-colors.md)** - Using icon_color attribute for color-coded dashboards
- **[Actions](actions.md)** - Scheduling, chart, and data actions for automations and dashboards
- **[Actions](actions.md)** - Custom actions (service endpoints) and how to use them
- **[Chart Examples](chart-examples.md)** - ✨ ApexCharts visualizations with screenshots
- **[Automation Examples](automation-examples.md)** - Ready-to-use automation recipes
- **[Troubleshooting](troubleshooting.md)** - Common issues and solutions

View file

@ -2,9 +2,7 @@
Learn how Best Price and Peak Price periods work, and how to configure them for your needs.
:::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.
:::
> **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
@ -57,9 +55,6 @@ The integration sets different **initial defaults** because the features serve d
### Example Timeline
<details>
<summary>Show example: Timeline</summary>
```
00:00 ████████████████ Best Price Period (cheap prices)
04:00 ░░░░░░░░░░░░░░░░ Normal
@ -69,8 +64,6 @@ The integration sets different **initial defaults** because the features serve d
20:00 ████████████████ Best Price Period (cheap prices)
```
</details>
---
## How It Works
@ -121,39 +114,26 @@ Think of it like this:
**Best Price:** How much MORE than the daily minimum can a price be?
<details>
<summary>Show example: Search Range Flexibility</summary>
```
Daily MIN: 20 ct/kWh
Flexibility: 15% (default)
→ Search for times ≤ 23 ct/kWh (20 + 15%)
```
</details>
**Peak Price:** How much LESS than the daily maximum can a price be?
<details>
<summary>Show example: Peak Price Flexibility</summary>
```
Daily MAX: 40 ct/kWh
Flexibility: -15% (default)
→ Search for times ≥ 34 ct/kWh (40 - 15%)
```
</details>
**Why flexibility?** Prices rarely stay at exactly MIN/MAX. Flexibility lets you capture realistic time windows.
#### 2. Ensure Quality (Distance from Average)
Periods must be meaningfully different from the daily average:
<details>
<summary>Show example: Distance from Average</summary>
```
Daily AVG: 30 ct/kWh
Minimum distance: 5% (default)
@ -162,17 +142,12 @@ Best Price: Must be ≤ 28.5 ct/kWh (30 - 5%)
Peak Price: Must be ≥ 31.5 ct/kWh (30 + 5%)
```
</details>
**Why?** This prevents marking mediocre times as "best" just because they're slightly below average.
#### 3. Check Duration
Periods must be long enough to be practical:
<details>
<summary>Show example: Minimum Period Duration</summary>
```
Default: 60 minutes minimum
@ -180,8 +155,6 @@ Default: 60 minutes minimum
90-minute period → Kept ✓
```
</details>
#### 4. Apply Optional Filters
You can optionally require:
@ -192,9 +165,6 @@ You can optionally require:
Isolated price spikes are automatically detected and smoothed to prevent unnecessary period fragmentation:
<details>
<summary>Show example: Automatic Price Spike Smoothing</summary>
```
Original prices: 18, 19, 35, 20, 19 ct ← 35 ct is an isolated outlier
Smoothed: 18, 19, 19, 20, 19 ct ← Spike replaced with trend prediction
@ -202,8 +172,6 @@ Smoothed: 18, 19, 19, 20, 19 ct ← Spike replaced with trend predictio
Result: Continuous period 00:00-01:15 instead of split periods
```
</details>
**Important:**
- Original prices are always preserved (min/max/avg show real values)
- Smoothing only affects which intervals are combined into periods
@ -213,9 +181,6 @@ Result: Continuous period 00:00-01:15 instead of split periods
**Timeline for a typical day:**
<details>
<summary>Show example: Timeline</summary>
```
Hour: 00 01 02 03 04 05 06 07 08 09 10 11 12 13 14 15 16 17 18 19 20 21 22 23
Price: 18 19 20 28 29 30 35 34 33 32 30 28 25 24 26 28 30 32 31 22 21 20 19 18
@ -231,8 +196,6 @@ Peak Price (-15% flex = ≥29.75 ct):
06:00-11:00 (5h)
```
</details>
---
## Configuration Guide
@ -245,16 +208,11 @@ Peak Price (-15% flex = ≥29.75 ct):
**Default:** 15% (Best Price), -15% (Peak Price)
**Range:** 0-100%
<details>
<summary>Show YAML: Flexibility</summary>
```yaml
best_price_flex: 15 # Can be up to 15% more expensive than daily MIN
peak_price_flex: -15 # Can be up to 15% less expensive than daily MAX
```
</details>
**When to adjust:**
- **Increase (20-25%)** → Find more/longer periods
@ -268,16 +226,11 @@ peak_price_flex: -15 # Can be up to 15% less expensive than daily MAX
**Default:** 60 minutes (Best Price), 30 minutes (Peak Price)
**Range:** 15-240 minutes
<details>
<summary>Show YAML: Minimum Period Length</summary>
```yaml
best_price_min_period_length: 60
peak_price_min_period_length: 30
```
</details>
**When to adjust:**
- **Increase (90-120 min)** → Only show longer periods (e.g., for heat pump cycles)
@ -289,16 +242,11 @@ peak_price_min_period_length: 30
**Default:** 5%
**Range:** 0-20%
<details>
<summary>Show YAML: Distance from Average</summary>
```yaml
best_price_min_distance_from_avg: 5
peak_price_min_distance_from_avg: 5
```
</details>
**When to adjust:**
- **Increase (5-10%)** → Only show clearly better times
@ -314,16 +262,11 @@ peak_price_min_distance_from_avg: 5
**Default:** `any` (disabled)
**Options:** `any` | `cheap` | `very_cheap` (Best Price) | `expensive` | `very_expensive` (Peak Price)
<details>
<summary>Show YAML: Level Filter</summary>
```yaml
best_price_max_level: any # Show any period below average
best_price_max_level: cheap # Only show if at least one interval is CHEAP
```
</details>
**Use case:** "Only notify me when prices are objectively cheap/expensive"
** Volatility Thresholds:** The level filter also supports volatility-based levels (`volatility_low`, `volatility_medium`, `volatility_high`). These use **fixed internal thresholds** (LOW < 10%, MEDIUM < 20%, HIGH 20%) that are separate from the sensor volatility thresholds you configure in the UI. This separation ensures that changing sensor display preferences doesn't affect period calculation behavior.
@ -334,16 +277,11 @@ best_price_max_level: cheap # Only show if at least one interval is CHEAP
**Default:** 0 (strict)
**Range:** 0-10
<details>
<summary>Show YAML: Gap Tolerance</summary>
```yaml
best_price_max_level: cheap
best_price_max_level_gap_count: 2 # Allow up to 2 NORMAL intervals per period
```
</details>
**Use case:** "Don't split periods just because one interval isn't perfectly CHEAP"
### Tweaking Strategy: What to Adjust First?
@ -354,81 +292,56 @@ When you're not happy with the default behavior, adjust settings in this order:
If you're not finding enough periods:
<details>
<summary>Show YAML: Relaxation Defaults</summary>
```yaml
enable_min_periods_best: true # Already default!
min_periods_best: 2 # Already default!
relaxation_attempts_best: 11 # Already default!
```
</details>
**Why start here?** Relaxation automatically finds the right balance for each day. Much easier than manual tuning.
#### 2. **Adjust Period Length (Simple)**
If periods are too short/long for your use case:
<details>
<summary>Show YAML: Longer or Shorter Periods</summary>
```yaml
best_price_min_period_length: 90 # Increase from 60 for longer periods
# OR
best_price_min_period_length: 45 # Decrease from 60 for shorter periods
```
</details>
**Safe to change:** This only affects duration, not price selection logic.
#### 3. **Fine-tune Flexibility (Moderate)**
If you consistently want more/fewer periods:
<details>
<summary>Show YAML: Flexibility Tuning</summary>
```yaml
best_price_flex: 20 # Increase from 15% for more periods
# OR
best_price_flex: 10 # Decrease from 15% for stricter selection
```
</details>
**⚠️ Watch out:** Values >25% may conflict with distance filter. Use relaxation instead.
#### 4. **Adjust Distance from Average (Advanced)**
Only if periods seem "mediocre" (not really cheap/expensive):
<details>
<summary>Show YAML: Distance from Average</summary>
```yaml
best_price_min_distance_from_avg: 10 # Increase from 5% for stricter quality
```
</details>
**⚠️ Careful:** High values (>10%) can make it impossible to find periods on flat price days.
#### 5. **Enable Level Filter (Expert)**
Only if you want absolute quality requirements:
<details>
<summary>Show YAML: Strict Level Filter</summary>
```yaml
best_price_max_level: cheap # Only show objectively CHEAP periods
```
</details>
**⚠️ Very strict:** Many days may have zero qualifying periods. **Always enable relaxation when using this!**
### Common Mistakes to Avoid
@ -469,9 +382,6 @@ On days with a sharp price dip (e.g. solar midday surplus), the Best Price Perio
**Configuration:**
<details>
<summary>Show YAML: Simple Best Price</summary>
```yaml
# Use defaults - no configuration needed!
best_price_flex: 15 # (default)
@ -479,8 +389,6 @@ best_price_min_period_length: 60 # (default)
best_price_min_distance_from_avg: 5 # (default)
```
</details>
**What you get:**
- 1-3 periods per day with prices ≤ MIN + 15%
@ -489,9 +397,6 @@ best_price_min_distance_from_avg: 5 # (default)
**Automation example:**
<details>
<summary>Show YAML: Dishwasher in Best Price Period</summary>
```yaml
automation:
- trigger:
@ -504,8 +409,6 @@ automation:
entity_id: switch.dishwasher
```
</details>
---
## Troubleshooting
@ -518,17 +421,12 @@ automation:
This is **expected behavior** on days with very uniform electricity prices. When prices vary by less than ~10% across the day (e.g. on sunny spring days with high solar generation), there is no meaningful second "cheap window" all hours are equally cheap. The integration automatically reduces the target to 1 period for that day.
<details>
<summary>Show YAML: Flat Day Detection</summary>
```yaml
min_periods_configured: 2
periods_found_total: 1
flat_days_detected: 1 # Uniform prices today → 1 period is the right answer
```
</details>
You don't need to change anything. This is the integration protecting you from artificial periods.
**If `relaxation_incomplete` is present (without `flat_days_detected`):**
@ -536,25 +434,15 @@ You don't need to change anything. This is the integration protecting you from a
Relaxation tried all configured attempts but couldn't reach your target. Options:
1. **Increase relaxation attempts** (tries more flexibility levels before giving up)
<details>
<summary>Show YAML example (increase relaxation attempts)</summary>
```yaml
relaxation_attempts_best: 12 # Default: 11
```
</details>
2. **Reduce minimum period count**
<details>
<summary>Show YAML example (reduce min periods)</summary>
```yaml
min_periods_best: 1 # Only require 1 period per day
```
</details>
3. **Check filter settings** very strict `best_price_min_distance_from_avg` values block relaxation
---
@ -566,40 +454,25 @@ Relaxation tried all configured attempts but couldn't reach your target. Options
**Common Solutions:**
1. **Check if relaxation is enabled**
<details>
<summary>Show YAML example (relaxation enabled)</summary>
```yaml
enable_min_periods_best: true # Should be true (default)
min_periods_best: 2 # Try to find at least 2 periods
```
</details>
2. **If still no periods, check filters**
- Look at sensor attributes: `relaxation_active` and `relaxation_level`
- If relaxation exhausted all attempts: Filters too strict or flat price day
3. **Try increasing flexibility slightly**
<details>
<summary>Show YAML example (increase flexibility)</summary>
```yaml
best_price_flex: 20 # Increase from default 15%
```
</details>
4. **Or reduce period length requirement**
<details>
<summary>Show YAML example (reduce period length)</summary>
```yaml
best_price_min_period_length: 45 # Reduce from default 60 minutes
```
</details>
### Periods Split Into Small Pieces
**Symptom:** Many short periods instead of one long period
@ -607,26 +480,16 @@ Relaxation tried all configured attempts but couldn't reach your target. Options
**Common Solutions:**
1. **If using level filter, add gap tolerance**
<details>
<summary>Show YAML example (level gap tolerance)</summary>
```yaml
best_price_max_level: cheap
best_price_max_level_gap_count: 2 # Allow 2 NORMAL intervals
```
</details>
2. **Slightly increase flexibility**
<details>
<summary>Show YAML example (wider flexibility)</summary>
```yaml
best_price_flex: 20 # From 15% → captures wider price range
```
</details>
3. **Check for price spikes**
- Automatic smoothing should handle this
- Check attribute: `period_interval_smoothed_count`
@ -637,7 +500,7 @@ Relaxation tried all configured attempts but couldn't reach your target. Options
**Key attributes to check:**
<details>
<summary>Show YAML: Key attributes to check</summary>
<summary>Show YAML: Sensor attribute reference for best_price_period</summary>
```yaml
# Entity: binary_sensor.<home_name>_best_price_period
@ -673,9 +536,6 @@ relaxation_incomplete: true # Some days couldn't reach the configured ta
These two values together quickly show whether the calculation achieved its goal:
<details>
<summary>Show YAML: Configured Target vs Found Periods</summary>
```yaml
min_periods_configured: 2 # You asked for 2 periods per day
periods_found_total: 6 # 3 days × 2 periods = fully satisfied ✅
@ -686,25 +546,18 @@ min_periods_configured: 2
periods_found_total: 5 # 3 days, but one day got only 1 period
```
</details>
Note that `periods_found_total` counts **all periods across today and tomorrow** so 4 on a two-day view means 2 per day on average.
**`flat_days_detected`**
This is the most important diagnostic for days with very uniform prices (e.g. sunny spring/summer days with high solar generation):
<details>
<summary>Show YAML: Flat Days Detected Attribute</summary>
```yaml
min_periods_configured: 2
periods_found_total: 1
flat_days_detected: 1 # ← This explains why you got 1 instead of 2
```
</details>
When prices barely change across the day typically a variation of less than 10% the integration automatically reduces the target from your configured value to 1. There is no meaningful second "best price window" when all prices are essentially equal.
**This is expected and correct behavior**, not a problem. It prevents the sensor from generating artificial periods that don't represent genuinely cheaper windows.
@ -713,17 +566,12 @@ When prices barely change across the day typically a variation of less than
This flag appears when even after all relaxation attempts, at least one day could not reach the configured minimum number of periods:
<details>
<summary>Show YAML: Relaxation Incomplete Attribute</summary>
```yaml
min_periods_configured: 2
periods_found_total: 1
relaxation_incomplete: true # ← Relaxation tried everything, still short
```
</details>
This is most common on very flat days (see above) or with very strict filter settings. If you see this repeatedly on normal days, consider:
- Reducing `min_periods_best` to 1
- Increasing `relaxation_attempts_best`
@ -752,7 +600,7 @@ This is **mathematically correct behavior** caused by how electricity prices are
**Example:**
<details>
<summary>Show YAML: Midnight Price Classification Changes</summary>
<summary>Show YAML: How identical prices can flip between best/peak across midnight</summary>
```yaml
# Day 1 (low volatility, narrow range)
@ -782,9 +630,6 @@ Daily average: 19 ct/kWh
Check the volatility sensors to understand if a period flip is meaningful:
<details>
<summary>Show YAML: Daily Volatility Check</summary>
```yaml
# Check daily volatility (available in integration)
sensor.<home_name>_today_s_price_volatility: 8.2% # Low volatility
@ -796,14 +641,12 @@ sensor.<home_name>_tomorrow_s_price_volatility: 7.9% # Also low
# - Consider ignoring period classification on such days
```
</details>
**Handling in Automations:**
You can make your automations volatility-aware:
<details>
<summary>Show YAML: Volatility-Aware Automation</summary>
<summary>Show YAML: 3 automation strategies for volatility-aware period control</summary>
```yaml
# Option 1: Only act on high-volatility days
@ -859,9 +702,6 @@ automation:
Each period sensor exposes day volatility and price statistics:
<details>
<summary>Show YAML: Per-Period Volatility Attributes</summary>
```yaml
binary_sensor.<home_name>_best_price_period:
day_volatility_%: 8.2 # Volatility % of the period's day
@ -870,8 +710,6 @@ binary_sensor.<home_name>_best_price_period:
day_price_span: 400.0 # Difference (max - min) in ct
```
</details>
These attributes allow automations to check: "Is the classification meaningful on this particular day?"
**Summary:**
@ -886,7 +724,7 @@ These attributes allow automations to check: "Is the classification meaningful o
For advanced configuration patterns and technical deep-dive, see:
- [Automation Examples](./automation-examples.md) - Real-world automation patterns
- [Chart Actions](./chart-actions.md) - Using the `tibber_prices.get_chartdata` action for custom visualizations
- [Actions](./actions.md) - Using the `tibber_prices.get_chartdata` action for custom visualizations
### Quick Reference

View file

@ -1,8 +1,6 @@
# Understanding Relaxation
:::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.
:::
> **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.
@ -14,17 +12,12 @@ Sometimes, strict filters find too few periods (or none). **Relaxation automatic
## How to Enable
<details>
<summary>Show YAML: Enable Relaxation</summary>
```yaml
enable_min_periods_best: true
min_periods_best: 2 # Try to find at least 2 periods per day
relaxation_attempts_best: 11 # Flex levels to test (default: 11 steps = 22 filter combinations)
```
</details>
**Good news:** Relaxation is **enabled by default** with sensible settings. Most users don't need to change anything here!
Set the matching `relaxation_attempts_peak` value when tuning Peak Price periods. Both sliders accept 1-12 attempts, and the default of 11 flex levels translates to 22 filter-combination tries (11 flex levels × 2 filter combos) for each of Best and Peak calculations. Lower it for quick feedback, or raise it when either sensor struggles to hit the minimum-period target on volatile days.
@ -104,26 +97,18 @@ Each attempt adds +3% flexibility and tries two filter combinations. The system
**Critical:** Each day relaxes **independently**:
<details>
<summary>Show formula: Each day relaxes independently</summary>
```
Day 1: Finds 2 periods with flex 15% (original) → No relaxation needed
Day 2: Needs flex 21% + level=any → Uses relaxed settings
Day 3: Finds 2 periods with flex 15% (original) → No relaxation needed
```
</details>
**Why?** Price patterns vary daily. Some days have clear cheap/expensive windows (strict filters work), others don't (relaxation needed).
## Diagnosing Relaxation Behavior
Check the period sensor attributes to understand what happened:
<details>
<summary>Show YAML: Diagnosing Relaxation Behavior</summary>
```yaml
# Entity: binary_sensor.<home_name>_best_price_period
@ -133,8 +118,6 @@ min_periods_configured: 2 # Your target
periods_found_total: 3 # What was actually found
```
</details>
| Attribute | Meaning |
|-----------|---------|
| `relaxation_active: false` | Original filters were sufficient |

View file

@ -1,788 +0,0 @@
# 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**: 1530 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.

View file

@ -208,15 +208,6 @@ explanations of each sensor's purpose, attributes, and automation examples.
| <span id="ref-data_lifecycle_status" class="entity-anchor"></span>`data_lifecycle_status` | Data Lifecycle Status | Datenlebenszyklus-Status | Datalivssyklus-status | Data Levenscyclus Status | Datalivscykelstatus | ✅ |
| <span id="ref-chart_data_export" class="entity-anchor"></span>`chart_data_export` | Chart Data Export | Diagramm-Datenexport | Diagramdataeksport | Grafiekdata Export | Diagramdataexport | ❌ |
| <span id="ref-chart_metadata" class="entity-anchor"></span>`chart_metadata` | Chart Metadata | Diagramm-Metadaten | Diagrammetadata | Grafiek Metadata | Diagrammetadata | ✅ |
### Other
| Entity ID suffix | 🇬🇧 English | 🇩🇪 Deutsch | 🇳🇴 Norsk | 🇳🇱 Nederlands | 🇸🇪 Svenska | Default |
|---|---|---|---|---|---|---|
| <span id="ref-day_pattern_today" class="entity-anchor"></span>`day_pattern_today` | Today's Price Pattern | Preismuster Heute | Prismønster i dag | Prijspatroon Vandaag | Prismönster Idag | ✅ |
| <span id="ref-day_pattern_tomorrow" class="entity-anchor"></span>`day_pattern_tomorrow` | Tomorrow's Price Pattern | Preismuster Morgen | Prismønster i morgen | Prijspatroon Morgen | Prismönster Imorgon | ❌ |
| <span id="ref-day_pattern_yesterday" class="entity-anchor"></span>`day_pattern_yesterday` | Yesterday's Price Pattern | Preismuster Gestern | Prismønster i går | Prijspatroon Gisteren | Prismönster Igår | ❌ |
## Binary Sensors
### Binary Sensors

View file

@ -1,8 +1,6 @@
# Average & Statistics Sensors
:::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.
:::
> **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.
@ -27,9 +25,6 @@ All average sensors support **two different calculation methods** for the state
**Why two values matter:**
<details>
<summary>Show YAML: Why two values matter</summary>
```yaml
# Example price data for one day:
# Prices: 10, 12, 13, 15, 80 ct/kWh (one extreme spike)
@ -38,8 +33,6 @@ All average sensors support **two different calculation methods** for the state
# Mean = 26 ct/kWh ← Mathematical average (affected by spike)
```
</details>
The median shows you what price level was **typical** during that period, while the mean shows the actual **average cost** if you consumed evenly throughout the period.
## Configuring the Display
@ -59,9 +52,6 @@ You can choose which value is displayed in the sensor state:
Both `price_mean` and `price_median` are always available as attributes:
<details>
<summary>Show YAML: Mean and Median in Automations</summary>
```yaml
# Example: Get both values regardless of display setting
sensor:
@ -83,8 +73,6 @@ sensor:
{% endif %}
```
</details>
## Practical Examples
**Example 1: Smart dishwasher control**
@ -92,7 +80,7 @@ sensor:
Run dishwasher only when price is significantly below the daily typical level:
<details>
<summary>Show YAML: Practical Examples</summary>
<summary>Show YAML: Automation — start dishwasher when cheap</summary>
```yaml
automation:
@ -120,7 +108,7 @@ automation:
Use mean for actual cost calculations:
<details>
<summary>Show YAML: Mean for Cost Calculations</summary>
<summary>Show YAML: Automation — cost-aware heating control</summary>
```yaml
automation:
@ -147,7 +135,7 @@ automation:
Use trailing average to understand recent price trends:
<details>
<summary>Show YAML: Practical Examples</summary>
<summary>Show YAML: Automation — EV charging based on rolling average</summary>
```yaml
automation:

View file

@ -1,20 +1,13 @@
# Energy & Tax Attributes
:::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.
:::
> **Entity ID tip:** `<home_name>` is a placeholder for your Tibber home display name in Home Assistant. Entity IDs are derived from the displayed name (localized), so the exact slug may differ. **Can't find a sensor?** Use the **[Entity Reference (All Languages)](sensor-reference.md)** to search by name in your language.
Most price sensors include **energy price** and **tax** attributes that break down the total price into its components:
<details>
<summary>Show formula</summary>
```
total = energy_price + tax
```
</details>
These attributes appear on **all price sensors that display a raw price** (not on percentage, level, or trend sensors). The `energy_price` is the raw spot/market price, while `tax` includes all fees, surcharges, and taxes added by your electricity provider.
:::note Transition After Update
@ -40,7 +33,7 @@ After updating the integration, the `energy_price` and `tax` attributes will app
In countries like the Netherlands, solar feed-in compensation is based on the **raw energy/spot price**, not the total consumer price. The `energy_price` attribute gives you exactly this value — no more reverse-engineering from the total price with fragile template calculations.
<details>
<summary>Show YAML: Solar Feed-In and Net Metering</summary>
<summary>Show YAML: Automation — solar export or consume decision</summary>
```yaml
# Example: Decide whether to export solar power or consume it
@ -70,7 +63,7 @@ automation:
Understand how your electricity price is structured — useful for comparing across days or spotting trends in market prices vs. fees:
<details>
<summary>Show YAML: Price Composition Analysis</summary>
<summary>Show YAML: Template sensor — electricity tax share percentage</summary>
```yaml
# Template sensor showing tax share
@ -94,9 +87,6 @@ template:
Show users how today's average price splits into energy vs. tax:
<details>
<summary>Show YAML: Daily Cost Breakdown</summary>
```yaml
# Mushroom chips card showing the split
type: custom:mushroom-chips-card
@ -111,12 +101,10 @@ chips:
🏛️ {{ state_attr('sensor.<home_name>_price_today', 'tax_mean') | round(1) }} ct
```
</details>
## Country-Specific Calculations
The composition of the `tax` field varies by country (Norway, Sweden, Germany, Netherlands each have different fee structures). For detailed examples of how to build country-specific calculations using `input_number` helpers and template sensors — including **Dutch solar feed-in compensation (saldering)** — see the **[Community Examples](community-examples.md#country-specific-price-calculations)** page.
## In Chart Data Actions
The `energy_price` and `tax` fields are also available in the `get_chartdata` action. See [Chart Actions — Energy & Tax Fields](./chart-actions.md#energy--tax-fields) for details.
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.

View file

@ -2,9 +2,7 @@
> **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 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.
:::
> **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.
@ -156,9 +154,6 @@ See the `tibber_prices.get_chartdata` action documentation for a complete list o
**Example Usage:**
<details>
<summary>Show YAML: Example Usage</summary>
```yaml
# ApexCharts card consuming the sensor
type: custom:apexcharts-card
@ -168,15 +163,10 @@ series:
return entity.attributes.data;
```
</details>
**Migration Path:**
If you're currently using this sensor, consider migrating to the action:
<details>
<summary>Show YAML: Chart Data Export</summary>
```yaml
# Old approach (sensor)
- service: apexcharts_card.update
@ -191,5 +181,3 @@ If you're currently using this sensor, consider migrating to the action:
output_format: array_of_objects
response_variable: chart_data
```
</details>

View file

@ -1,8 +1,6 @@
# Ratings & Levels
:::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.
:::
> **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.
@ -33,15 +31,10 @@ Rating sensors classify prices relative to the **trailing 24-hour average**, ans
The integration calculates a **percentage difference** between the current price and the trailing 24-hour average:
<details>
<summary>Show formula: How Ratings Work</summary>
```
difference = ((current_price - trailing_avg) / abs(trailing_avg)) × 100%
```
</details>
This percentage is then classified:
| Rating | Condition (default) | Meaning |
@ -93,9 +86,6 @@ stateDiagram-v2
**Best Practice:** Always use the `rating_level` attribute (lowercase English) instead of the sensor state (which is translated to your HA language):
<details>
<summary>Show YAML: Usage in Automations</summary>
```yaml
# ✅ Correct — language-independent
condition:
@ -110,8 +100,6 @@ condition:
state: "Low" # "Niedrig" in German, "Lav" in Norwegian...
```
</details>
### Configuration
Rating thresholds can be adjusted in the options flow:

View file

@ -1,8 +1,6 @@
# Timing Sensors
:::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.
:::
> **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.
@ -39,9 +37,6 @@ For each period type (Best Price and Peak Price):
### Show Countdown to Next Cheap Window
<details>
<summary>Show YAML: Countdown to Next Cheap Window</summary>
```yaml
type: custom:mushroom-entity-card
entity: sensor.<home_name>_best_price_next_in_minutes
@ -49,12 +44,10 @@ name: Next Cheap Window
icon: mdi:clock-fast
```
</details>
### Display Period Progress Bar
<details>
<summary>Show YAML: Display Period Progress Bar</summary>
<summary>Show YAML: Bar card for period progress</summary>
```yaml
type: custom:bar-card
@ -79,7 +72,7 @@ severity:
### Notify When Period Is Almost Over
<details>
<summary>Show YAML: Period Ending Notification</summary>
<summary>Show YAML: Automation — notify when best price period is ending</summary>
```yaml
automation:

View file

@ -1,8 +1,6 @@
# Trend Sensors
:::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.
:::
> **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:
@ -192,9 +190,6 @@ A **countdown timer** companion to the Next Price Trend Change sensor above. Ins
**Example automation:**
<details>
<summary>Show YAML: Example automation</summary>
```yaml
trigger:
- platform: numeric_state
@ -206,8 +201,6 @@ action:
message: "Price trend is about to change direction!"
```
</details>
**Tip:** Use this sensor for "HOW LONG" and the Next Price Trend Change sensor (timestamp) for "WHEN".
---
@ -238,9 +231,6 @@ A natural intuition is to treat trend states like a stock ticker:
For most appliances (dishwasher, washing machine, dryer), a single outlook sensor is enough:
<details>
<summary>Show YAML: Basic Automation Pattern</summary>
```yaml
# Example: Start dishwasher when prices are favorable
trigger:
@ -258,8 +248,6 @@ action:
entity_id: switch.dishwasher
```
</details>
### Combining Multiple Windows
When short-term and long-term trends disagree, you get richer insight:

View file

@ -1,8 +1,6 @@
# Volatility Sensors
:::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.
:::
> **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.
@ -61,9 +59,6 @@ For automations, it is strongly recommended to use the `price_volatility` attrib
**Good Example (Robust Automation):**
This automation triggers only if the volatility is classified as `high` or `very_high`, respecting your central settings and working independently of the system language.
<details>
<summary>Show YAML: Good Example (Robust Automation)</summary>
```yaml
automation:
- alias: "Enable battery optimization only on volatile days"
@ -76,8 +71,6 @@ automation:
entity_id: input_boolean.battery_optimization_enabled
```
</details>
---
### Avoid Hard-Coding Numeric Thresholds
@ -88,9 +81,6 @@ You might be tempted to use the numeric `price_coefficient_variation_%` attribut
**Bad Example (Brittle Automation):**
This automation uses a hard-coded value. If you later change the "High" threshold in the integration's options to 35%, this automation will not respect that change and might trigger at the wrong time.
<details>
<summary>Show YAML: Bad Example (Brittle Automation)</summary>
```yaml
automation:
- alias: "Brittle - Enable battery optimization"
@ -107,6 +97,4 @@ automation:
entity_id: input_boolean.battery_optimization_enabled
```
</details>
By following the "Good Example", your automations become simpler, more readable, and much easier to maintain.

View file

@ -73,9 +73,6 @@ When reporting issues, debug logs help identify the problem quickly.
Add this to your `configuration.yaml`:
<details>
<summary>Show YAML: configuration.yaml Logging</summary>
```yaml
logger:
default: warning
@ -83,17 +80,12 @@ logger:
custom_components.tibber_prices: debug
```
</details>
Restart Home Assistant for the change to take effect.
### Targeted Logging
For specific subsystems, you can enable logging selectively:
<details>
<summary>Show YAML: Targeted Subsystem Logging</summary>
```yaml
logger:
default: warning
@ -111,8 +103,6 @@ logger:
custom_components.tibber_prices.sensor: debug
```
</details>
### Temporary Debug Logging (No Restart)
You can also enable debug logging temporarily from the HA UI:
@ -120,15 +110,10 @@ You can also enable debug logging temporarily from the HA UI:
1. Go to **Developer Tools → Services**
2. Call service: `logger.set_level`
3. Data:
<details>
<summary>Show YAML example (temporary logger.set_level payload)</summary>
```yaml
custom_components.tibber_prices: debug
```
</details>
This resets when HA restarts.
### Downloading Diagnostics

View file

@ -71,19 +71,11 @@ const sidebars: SidebarsConfig = {
collapsible: true,
collapsed: false,
},
{
type: 'category',
label: '⚡ Actions',
link: { type: 'doc', id: 'actions' },
items: ['actions', 'scheduling-actions', 'chart-actions', 'data-actions'],
collapsible: true,
collapsed: false,
},
{
type: 'category',
label: '📖 Reference',
link: { type: 'doc', id: 'sensor-reference' },
items: ['sensor-reference'],
items: ['sensor-reference', 'actions'],
collapsible: true,
collapsed: false,
},

View file

@ -1,258 +0,0 @@
"""
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,
}
)

View file

@ -1,602 +0,0 @@
"""
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