feat(best_price,peak_price): add optional extension to VERY_CHEAP/VERY_EXPENSIVE intervals

After period detection, optionally walk left/right from each period boundary
to absorb adjacent VERY_CHEAP (best price) or VERY_EXPENSIVE (peak price)
intervals (step 7.5 in the pipeline).

New constants: CONF_BEST_PRICE_EXTEND_TO_VERY_CHEAP, CONF_BEST_PRICE_MAX_EXTENSION_INTERVALS,
CONF_PEAK_PRICE_EXTEND_TO_VERY_EXPENSIVE, CONF_PEAK_PRICE_MAX_EXTENSION_INTERVALS.
Defaults: off / 4 intervals (1 hour per side). Hard maximum: 12 intervals (3 hours).

Config stored under "extension_settings" section, reflected in period hash
for correct cache invalidation.

New module: coordinator/period_handlers/shape_extension.py handles the
boundary walk, stat recalculation, and extension_intervals_added bookkeeping.

Impact: Users can opt-in to wider best/peak price windows that include
extreme-level adjacent intervals, reducing missed very cheap/expensive slots
at period edges.
This commit is contained in:
Julian Pawlowski 2026-04-11 21:24:44 +00:00
parent 447dc907e6
commit b7f1efce1f
12 changed files with 10006 additions and 9485 deletions

View file

@ -12,7 +12,9 @@ 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_MAX_EXTENSION_INTERVALS,
CONF_BEST_PRICE_MAX_LEVEL,
CONF_BEST_PRICE_MAX_LEVEL_GAP_COUNT,
CONF_BEST_PRICE_MIN_DISTANCE_FROM_AVG,
@ -23,7 +25,9 @@ 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_MAX_EXTENSION_INTERVALS,
CONF_PEAK_PRICE_MAX_LEVEL_GAP_COUNT,
CONF_PEAK_PRICE_MIN_DISTANCE_FROM_AVG,
CONF_PEAK_PRICE_MIN_LEVEL,
@ -49,7 +53,9 @@ 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_MAX_EXTENSION_INTERVALS,
DEFAULT_BEST_PRICE_MAX_LEVEL,
DEFAULT_BEST_PRICE_MAX_LEVEL_GAP_COUNT,
DEFAULT_BEST_PRICE_MIN_DISTANCE_FROM_AVG,
@ -59,7 +65,9 @@ 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_MAX_EXTENSION_INTERVALS,
DEFAULT_PEAK_PRICE_MAX_LEVEL_GAP_COUNT,
DEFAULT_PEAK_PRICE_MIN_DISTANCE_FROM_AVG,
DEFAULT_PEAK_PRICE_MIN_LEVEL,
@ -86,6 +94,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_MIN_PERIOD_LENGTH,
MAX_MIN_PERIODS,
@ -618,6 +627,7 @@ def get_best_price_schema(
period_settings = options.get("period_settings", {})
flexibility_settings = options.get("flexibility_settings", {})
relaxation_settings = options.get("relaxation_and_target_periods", {})
extension_settings = options.get("extension_settings", {})
# Get current values for override display
min_period_length = int(
@ -633,6 +643,12 @@ 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)
)
# Build section schemas with optional override warnings
period_warning = get_section_override_warning("best_price", "period_settings", overrides, translations) or {}
@ -754,6 +770,28 @@ 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,
)
),
}
),
{"collapsed": True},
),
}
)
@ -779,6 +817,7 @@ def get_peak_price_schema(
period_settings = options.get("period_settings", {})
flexibility_settings = options.get("flexibility_settings", {})
relaxation_settings = options.get("relaxation_and_target_periods", {})
extension_settings = options.get("extension_settings", {})
# Get current values for override display
min_period_length = int(
@ -794,6 +833,12 @@ 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)
)
# Build section schemas with optional override warnings
period_warning = get_section_override_warning("peak_price", "period_settings", overrides, translations) or {}
@ -915,6 +960,28 @@ 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,
)
),
}
),
{"collapsed": True},
),
}
)

View file

@ -68,6 +68,10 @@ 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_PEAK_PRICE_EXTEND_TO_VERY_EXPENSIVE = "peak_price_extend_to_very_expensive"
CONF_PEAK_PRICE_MAX_EXTENSION_INTERVALS = "peak_price_max_extension_intervals"
ATTRIBUTION = "Data provided by Tibber"
@ -131,6 +135,10 @@ 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_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
# Validation limits (used in GUI schemas and server-side validation)
# These ensure consistency between frontend and backend validation
@ -139,6 +147,7 @@ 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)
MIN_PERIOD_LENGTH = 15 # Minimum period length in minutes (1 quarter hour)
MAX_MIN_PERIOD_LENGTH = 180 # Maximum for minimum period length setting (3 hours - realistic for required minimum)
@ -407,6 +416,13 @@ 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_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,
},
}

View file

@ -28,6 +28,9 @@ 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,
@ -80,5 +83,6 @@ __all__ = [
"calculate_periods",
"calculate_periods_with_relaxation",
"detect_day_patterns",
"extend_periods_for_shape",
"filter_price_outliers",
]

View file

@ -25,6 +25,7 @@ from .period_building import (
from .period_statistics import (
extract_period_summaries,
)
from .shape_extension import extend_periods_for_shape
from .types import TibberPricesThresholdConfig
# Flex limits to prevent degenerate behavior (see docs/development/period-calculation-theory.md)
@ -209,6 +210,20 @@ def calculate_periods(
time=time,
)
# Step 7.5: Extend periods into adjacent VERY_CHEAP / VERY_EXPENSIVE intervals
# This is an opt-in feature (disabled by default) that adds contiguous
# extreme-level intervals on each side of an already-found period.
if config.extend_to_extreme and config.max_extension_intervals > 0:
period_summaries = extend_periods_for_shape(
period_summaries,
all_prices_sorted,
price_context,
reverse_sort=reverse_sort,
max_extension_intervals=config.max_extension_intervals,
thresholds=thresholds,
time=time,
)
# Step 8: Cross-day extension for late-night periods
# If a best-price period ends near midnight and tomorrow has continued low prices,
# extend the period across midnight to give users the full cheap window

View file

@ -0,0 +1,258 @@
"""
Shape-based period extension: extend periods into adjacent VERY_CHEAP/VERY_EXPENSIVE intervals.
After periods are identified by the core algorithm, this module optionally extends
each period's boundaries to include any directly-adjacent intervals that carry the
most extreme price level relevant to the period type:
- Best price periods extend into VERY_CHEAP neighbouring intervals
- Peak price periods extend into VERY_EXPENSIVE neighbouring intervals
Extension is purely additive and opt-in (disabled by default). It does not affect
the core period-finding logic; periods that would not normally be found are not
created by this step.
"""
from __future__ import annotations
import statistics
from datetime import timedelta
from typing import TYPE_CHECKING, Any
from custom_components.tibber_prices.const import (
PRICE_LEVEL_VERY_CHEAP,
PRICE_LEVEL_VERY_EXPENSIVE,
)
from custom_components.tibber_prices.utils.price import (
aggregate_period_levels,
aggregate_period_ratings,
)
from .period_statistics import (
calculate_aggregated_rating_difference,
calculate_period_price_diff,
calculate_period_price_statistics,
)
if TYPE_CHECKING:
from datetime import datetime
from custom_components.tibber_prices.coordinator.time_service import TibberPricesTimeService
from .types import TibberPricesThresholdConfig
_INTERVAL_DURATION = timedelta(minutes=15)
def extend_periods_for_shape( # noqa: PLR0913 - Extension requires all context params
periods: list[dict[str, Any]],
all_prices: list[dict[str, Any]],
price_context: dict[str, Any],
*,
reverse_sort: bool,
max_extension_intervals: int,
thresholds: TibberPricesThresholdConfig,
time: TibberPricesTimeService,
) -> list[dict[str, Any]]:
"""
Extend each period into adjacent VERY_CHEAP or VERY_EXPENSIVE intervals.
For best price periods (reverse_sort=False): extend into VERY_CHEAP neighbours.
For peak price periods (reverse_sort=True): extend into VERY_EXPENSIVE neighbours.
Only intervals that are directly contiguous with the period and carry the
target level are added. At most *max_extension_intervals* are consumed on
each side independently. Period statistics are fully recalculated after
any extension.
Args:
periods: Period summary dicts from ``extract_period_summaries``.
all_prices: All enriched price intervals (yesterday + today + tomorrow).
price_context: Dict with ``ref_prices`` and ``avg_prices`` per calendar day.
reverse_sort: ``True`` for peak price, ``False`` for best price.
max_extension_intervals: Maximum extra intervals that may be added per side.
thresholds: Threshold configuration for level / rating aggregation.
time: Time-service instance used to resolve ``startsAt`` timestamps.
Returns:
Updated list of period dicts, potentially with extended boundaries and
recalculated statistics. Unmodified periods are returned as-is.
"""
if not periods or max_extension_intervals <= 0:
return periods
target_level = PRICE_LEVEL_VERY_EXPENSIVE if reverse_sort else PRICE_LEVEL_VERY_CHEAP
# Build a lookup dict: local datetime → full interval dict
interval_index: dict[datetime, dict[str, Any]] = {}
for iv in all_prices:
t = time.get_interval_time(iv)
if t is not None:
interval_index[t] = iv
return [
_extend_period_edges(
period,
interval_index,
target_level=target_level,
max_intervals=max_extension_intervals,
thresholds=thresholds,
price_context=price_context,
)
for period in periods
]
# ── private helpers ────────────────────────────────────────────────────────────
def _extend_period_edges( # noqa: PLR0913, PLR0912, PLR0915 - Period edge extension requires many args, branches, and statements
period: dict[str, Any],
interval_index: dict[datetime, dict[str, Any]],
*,
target_level: str,
max_intervals: int,
thresholds: TibberPricesThresholdConfig,
price_context: dict[str, Any],
) -> dict[str, Any]:
"""
Consume adjacent target-level intervals on both edges of a period.
The original period dict is never mutated; a new dict is returned.
If no extension is possible, the original dict is returned unchanged.
Args:
period: Period summary dict with ``start`` and ``end`` datetime keys.
interval_index: Lookup map of ``{starts_at_datetime: interval_dict}``.
target_level: ``"VERY_CHEAP"`` or ``"VERY_EXPENSIVE"``.
max_intervals: Maximum intervals that may be added on each side.
thresholds: Threshold config for aggregation helpers.
price_context: Reference prices / averages per calendar day.
Returns:
Extended (or original) period summary dict.
"""
start: datetime = period["start"]
end: datetime = period["end"]
# ``end`` is the exclusive boundary: the last included interval starts at
# ``end - _INTERVAL_DURATION``.
# ── walk LEFT (earlier than period start) ─────────────────────────────────
left_additions: list[dict[str, Any]] = []
cursor = start - _INTERVAL_DURATION
for _ in range(max_intervals):
iv = interval_index.get(cursor)
if iv is None or iv.get("level") != target_level:
break
left_additions.insert(0, iv)
cursor -= _INTERVAL_DURATION
# ── walk RIGHT (later than period end) ────────────────────────────────────
right_additions: list[dict[str, Any]] = []
cursor = end # first interval AFTER the period
for _ in range(max_intervals):
iv = interval_index.get(cursor)
if iv is None or iv.get("level") != target_level:
break
right_additions.append(iv)
cursor += _INTERVAL_DURATION
total_added = len(left_additions) + len(right_additions)
if total_added == 0:
return period
# ── rebuild full interval list for the extended period ────────────────────
original_intervals = _collect_original_intervals(start, end, interval_index)
all_period_intervals = left_additions + original_intervals + right_additions
# ── recalculate boundaries ────────────────────────────────────────────────
new_start = start - _INTERVAL_DURATION * len(left_additions)
new_end = end + _INTERVAL_DURATION * len(right_additions)
new_duration_minutes = int((new_end - new_start).total_seconds() // 60)
new_interval_count = len(all_period_intervals)
# ── recalculate price statistics ──────────────────────────────────────────
price_stats = calculate_period_price_statistics(all_period_intervals)
period_price_diff, period_price_diff_pct = calculate_period_price_diff(
price_stats["price_mean"], new_start, price_context
)
rating_diff_pct = calculate_aggregated_rating_difference(all_period_intervals)
# ── recalculate level / rating aggregates ─────────────────────────────────
new_level = aggregate_period_levels(all_period_intervals)
new_rating: str | None = None
if thresholds.threshold_low is not None and thresholds.threshold_high is not None:
new_rating, _ = aggregate_period_ratings(
all_period_intervals,
thresholds.threshold_low,
thresholds.threshold_high,
)
# ── recalculate volatility (coefficient of variation) ────────────────────
prices_for_vol = [float(p["total"]) for p in all_period_intervals if "total" in p]
cv_pct: float | None = None
if len(prices_for_vol) >= 2: # noqa: PLR2004
mean_p = statistics.mean(prices_for_vol)
if mean_p > 0:
cv_pct = round(statistics.stdev(prices_for_vol) / mean_p * 100, 1)
# ── assemble updated period dict (keep structural fields, update statistics) ─
reverse_sort = target_level == PRICE_LEVEL_VERY_EXPENSIVE
updated: dict[str, Any] = {
**period,
# Time fields
"start": new_start,
"end": new_end,
"duration_minutes": new_duration_minutes,
# Core decision attributes
"level": new_level,
"rating_level": new_rating,
"rating_difference_%": rating_diff_pct,
# Price statistics
"price_mean": price_stats["price_mean"],
"price_median": price_stats["price_median"],
"price_min": price_stats["price_min"],
"price_max": price_stats["price_max"],
"price_spread": price_stats["price_spread"],
"price_coefficient_variation_%": cv_pct,
# Detail
"period_interval_count": new_interval_count,
# Extension metadata
"extension_intervals_added": total_added,
}
# Refresh period price diff (replaces old value from base period)
if reverse_sort:
updated.pop("period_price_diff_from_daily_min", None)
updated.pop("period_price_diff_from_daily_min_%", None)
if period_price_diff is not None:
updated["period_price_diff_from_daily_max"] = period_price_diff
if period_price_diff_pct is not None:
updated["period_price_diff_from_daily_max_%"] = period_price_diff_pct
else:
updated.pop("period_price_diff_from_daily_max", None)
updated.pop("period_price_diff_from_daily_max_%", None)
if period_price_diff is not None:
updated["period_price_diff_from_daily_min"] = period_price_diff
if period_price_diff_pct is not None:
updated["period_price_diff_from_daily_min_%"] = period_price_diff_pct
return updated
def _collect_original_intervals(
start: datetime,
end: datetime,
interval_index: dict[datetime, dict[str, Any]],
) -> list[dict[str, Any]]:
"""Reconstruct the ordered interval list for an existing period from the index."""
result: list[dict[str, Any]] = []
cursor = start
while cursor < end:
iv = interval_index.get(cursor)
if iv is not None:
result.append(iv)
cursor += _INTERVAL_DURATION
return result

View file

@ -56,6 +56,8 @@ 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)
class TibberPricesPeriodData(NamedTuple):

View file

@ -225,6 +225,41 @@ 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
# Cache the result
self._config_cache[cache_key] = config
self._config_cache_valid = True
@ -702,6 +737,8 @@ 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"],
)
best_periods = calculate_periods_with_relaxation(
all_prices,
@ -783,6 +820,8 @@ 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"],
)
peak_periods = calculate_periods_with_relaxation(
all_prices,

View file

@ -238,6 +238,18 @@
"min_periods_best": "Mindestanzahl von Bestpreis-Zeiträumen pro Tag, die angestrebt werden sollen. Filter werden schrittweise gelockert, um diese Anzahl zu erreichen. Nur aktiv wenn 'Mindestanzahl anstreben' aktiviert ist. Standard: 1",
"relaxation_attempts_best": "Wie viele Flexibilitätsstufen (Versuche) zu versuchen sind, bevor aufgegeben wird. Jeder Versuch führt alle Filterkombinationen auf der neuen Flexibilitätsstufe aus. Mehr Versuche erhöhen die Chance, zusätzliche Zeiträume zu finden, kosten aber mehr Verarbeitungszeit."
}
},
"extension_settings": {
"name": "Periodenrand-Erweiterung",
"description": "Erkannte Bestpreisperioden optional an beiden Rändern erweitern, um angrenzende sehr günstige Intervalle aufzunehmen.",
"data": {
"best_price_extend_to_very_cheap": "Auf sehr günstige Intervalle erweitern",
"best_price_max_extension_intervals": "Maximale Erweiterungsintervalle"
},
"data_description": {
"best_price_extend_to_very_cheap": "Wenn aktiviert, erweitern sich erkannte Bestpreisperioden nach außen, um angrenzende Intervalle mit dem Preisniveau 'Sehr günstig' aufzunehmen. So werden extrem günstige Intervalle an den Rändern erkannter Perioden besser erfasst.",
"best_price_max_extension_intervals": "Maximale Anzahl zusätzlicher Intervalle pro Seite (linker und rechter Rand). Jedes Intervall dauert 15 Minuten. Beispiel: 4 Intervalle = bis zu 1 Stunde Erweiterung pro Rand. Standard: 4"
}
}
},
"submit": "↩ Speichern & Zurück"
@ -285,6 +297,18 @@
"min_periods_peak": "Mindestanzahl an Spitzenpreis-Zeiträumen, die pro Tag angestrebt werden. Filter werden schrittweise gelockert, um diese Anzahl zu erreichen. Nur aktiv, wenn 'Mindestanzahl Zeiträume anstreben' aktiviert ist. Standard: 1",
"relaxation_attempts_peak": "Wie viele Flex-Stufen (Versuche) nacheinander ausprobiert werden, bevor aufgegeben wird. Jeder Versuch testet alle Filterkombinationen auf der neuen Flex-Stufe. Mehr Versuche erhöhen die Chance auf zusätzliche Spitzenpreis-Zeiträume, benötigen aber etwas mehr Rechenzeit."
}
},
"extension_settings": {
"name": "Periodenrand-Erweiterung",
"description": "Erkannte Spitzenpreisperioden optional an beiden Rändern erweitern, um angrenzende sehr teure Intervalle aufzunehmen.",
"data": {
"peak_price_extend_to_very_expensive": "Auf sehr teure Intervalle erweitern",
"peak_price_max_extension_intervals": "Maximale Erweiterungsintervalle"
},
"data_description": {
"peak_price_extend_to_very_expensive": "Wenn aktiviert, erweitern sich erkannte Spitzenpreisperioden nach außen, um angrenzende Intervalle mit dem Preisniveau 'Sehr teuer' aufzunehmen. So werden extrem teure Intervalle an den Rändern erkannter Perioden besser erfasst.",
"peak_price_max_extension_intervals": "Maximale Anzahl zusätzlicher Intervalle pro Seite (linker und rechter Rand). Jedes Intervall dauert 15 Minuten. Beispiel: 4 Intervalle = bis zu 1 Stunde Erweiterung pro Rand. Standard: 4"
}
}
},
"submit": "↩ Speichern & Zurück"

View file

@ -249,6 +249,18 @@
"min_periods_best": "Minimum number of best price periods to aim for per day. Filters will be relaxed step-by-step to try achieving this count. Only active when 'Achieve Minimum Count' is enabled. Default: 1",
"relaxation_attempts_best": "How many flex levels (attempts) to try before giving up. Each attempt runs all filter combinations at the new flex level. More attempts increase the chance of finding additional periods at the cost of longer processing time."
}
},
"extension_settings": {
"name": "Period Edge Extension",
"description": "Optionally extend detected best price periods at both edges to absorb adjacent very cheap intervals.",
"data": {
"best_price_extend_to_very_cheap": "Extend to Very Cheap Intervals",
"best_price_max_extension_intervals": "Maximum Extension Intervals"
},
"data_description": {
"best_price_extend_to_very_cheap": "When enabled, detected best price periods expand outward to absorb adjacent intervals with a 'Very cheap' price level. This widens low-price windows to better capture extremely cheap intervals at the edges of detected periods.",
"best_price_max_extension_intervals": "Maximum number of additional intervals to absorb per side (left and right edge). Each interval is 15 minutes. Example: 4 intervals = up to 1 hour extension per edge. Default: 4"
}
}
},
"submit": "↩ Save & Back"
@ -296,6 +308,18 @@
"min_periods_peak": "Minimum number of peak price periods to aim for per day. Filters will be relaxed step-by-step to try achieving this count. Only active when 'Achieve Minimum Count' is enabled. Default: 1",
"relaxation_attempts_peak": "How many flex levels (attempts) to try before giving up. Each attempt runs all filter combinations at the new flex level. More attempts increase the chance of finding additional peak periods at the cost of longer processing time."
}
},
"extension_settings": {
"name": "Period Edge Extension",
"description": "Optionally extend detected peak price periods at both edges to absorb adjacent very expensive intervals.",
"data": {
"peak_price_extend_to_very_expensive": "Extend to Very Expensive Intervals",
"peak_price_max_extension_intervals": "Maximum Extension Intervals"
},
"data_description": {
"peak_price_extend_to_very_expensive": "When enabled, detected peak price periods expand outward to absorb adjacent intervals with a 'Very expensive' price level. This widens high-price windows to better capture extremely expensive intervals at the edges of detected periods.",
"peak_price_max_extension_intervals": "Maximum number of additional intervals to absorb per side (left and right edge). Each interval is 15 minutes. Example: 4 intervals = up to 1 hour extension per edge. Default: 4"
}
}
},
"submit": "↩ Save & Back"

View file

@ -238,6 +238,18 @@
"min_periods_best": "Minimumsantall beste pris-perioder å sikte på per dag. Filtre vil bli lempet steg for steg for å forsøke å oppnå dette antallet. Kun aktiv når 'Oppnå minimumsantall' er aktivert. Standard: 1",
"relaxation_attempts_best": "Hvor mange fleksnivåer (forsøk) å prøve før man gir opp. Hvert forsøk kjører alle filterkombinasjoner på det nye fleksnivået. Flere forsøk øker sjansen for å finne flere perioder på bekostning av lengre behandlingstid."
}
},
"extension_settings": {
"name": "Utvidelse av perioderender",
"description": "Utvid eventuelt oppdagede bestprisperioder ved begge ender for å inkludere tilstøtende svært billige intervaller.",
"data": {
"best_price_extend_to_very_cheap": "Utvid til svært billige intervaller",
"best_price_max_extension_intervals": "Maksimale utvidelsesintervaller"
},
"data_description": {
"best_price_extend_to_very_cheap": "Når aktivert, utvider oppdagede bestprisperioder seg utover for å inkludere tilstøtende intervaller med prisnivået 'Svært billig'. Dette fanger opp ekstremt billige intervaller ved kantene av oppdagede perioder.",
"best_price_max_extension_intervals": "Maksimalt antall ekstra intervaller per side (venstre og høyre kant). Hvert intervall er 15 minutter. Eksempel: 4 intervaller = opptil 1 times utvidelse per kant. Standard: 4"
}
}
},
"submit": "↩ Lagre & tilbake"
@ -285,6 +297,18 @@
"min_periods_peak": "Minimum antall topp-pris-perioder å sikte mot per dag. Filtre vil bli lempet trinn for trinn for å prøve å oppnå dette antallet. Kun aktiv når 'Prøv å oppnå minimum antall perioder' er aktivert. Standard: 1",
"relaxation_attempts_peak": "Hvor mange fleksnivåer (forsøk) som testes før vi gir opp. Hvert forsøk kjører alle filterkombinasjoner på det nye fleksnivået. Flere forsøk øker sjansen for ekstra toppprisperioder, men tar litt lengre tid."
}
},
"extension_settings": {
"name": "Utvidelse av perioderender",
"description": "Utvid eventuelt oppdagede topprisperioder ved begge ender for å inkludere tilstøtende svært dyre intervaller.",
"data": {
"peak_price_extend_to_very_expensive": "Utvid til svært dyre intervaller",
"peak_price_max_extension_intervals": "Maksimale utvidelsesintervaller"
},
"data_description": {
"peak_price_extend_to_very_expensive": "Når aktivert, utvider oppdagede topprisperioder seg utover for å inkludere tilstøtende intervaller med prisnivået 'Svært dyrt'. Dette fanger opp ekstremt dyre intervaller ved kantene av oppdagede perioder.",
"peak_price_max_extension_intervals": "Maksimalt antall ekstra intervaller per side (venstre og høyre kant). Hvert intervall er 15 minutter. Eksempel: 4 intervaller = opptil 1 times utvidelse per kant. Standard: 4"
}
}
},
"submit": "↩ Lagre & tilbake"

View file

@ -238,6 +238,18 @@
"min_periods_best": "Minimaal aantal beste prijsperiodes om per dag na te streven. Filters worden stapsgewijs versoepeld om dit aantal te proberen te bereiken. Alleen actief wanneer 'Bereik Minimum Aantal' is ingeschakeld. Standaard: 1",
"relaxation_attempts_best": "Hoeveel flex niveaus (pogingen) te proberen voordat opgegeven wordt. Elke poging voert alle filtercombinaties uit op het nieuwe flex niveau. Meer pogingen verhogen de kans om extra periodes te vinden ten koste van langere verwerkingstijd."
}
},
"extension_settings": {
"name": "Uitbreiding aan perioderand",
"description": "Breid gedetecteerde beste-prijsperioden eventueel uit aan beide randen om aangrenzende zeer goedkope intervallen op te nemen.",
"data": {
"best_price_extend_to_very_cheap": "Uitbreiden met zeer goedkope intervallen",
"best_price_max_extension_intervals": "Maximale uitbreidingsintervallen"
},
"data_description": {
"best_price_extend_to_very_cheap": "Indien ingeschakeld, breiden gedetecteerde beste-prijsperioden zich uit aan de randen om aangrenzende intervallen met prijsniveau 'Zeer goedkoop' op te nemen. Dit vangt extreem goedkope intervallen op aan de randen van gedetecteerde perioden.",
"best_price_max_extension_intervals": "Maximaal aantal extra intervallen per kant (linker en rechter rand). Elk interval is 15 minuten. Voorbeeld: 4 intervallen = maximaal 1 uur uitbreiding per rand. Standaard: 4"
}
}
},
"submit": "↩ Opslaan & Terug"
@ -285,6 +297,18 @@
"min_periods_peak": "Minimaal aantal piekprijs periodes om per dag na te streven. Filters worden stapsgewijs versoepeld om dit aantal te proberen te bereiken. Alleen actief wanneer 'Bereik Minimum Aantal' is ingeschakeld. Standaard: 1",
"relaxation_attempts_peak": "Hoeveel flex niveaus (pogingen) te proberen voordat opgegeven wordt. Elke poging voert alle filtercombinaties uit op het nieuwe flex niveau. Meer pogingen verhogen de kans om extra piekperiodes te vinden ten koste van langere verwerkingstijd."
}
},
"extension_settings": {
"name": "Uitbreiding aan perioderand",
"description": "Breid gedetecteerde piekprijsperioden eventueel uit aan beide randen om aangrenzende zeer dure intervallen op te nemen.",
"data": {
"peak_price_extend_to_very_expensive": "Uitbreiden met zeer dure intervallen",
"peak_price_max_extension_intervals": "Maximale uitbreidingsintervallen"
},
"data_description": {
"peak_price_extend_to_very_expensive": "Indien ingeschakeld, breiden gedetecteerde piekprijsperioden zich uit aan de randen om aangrenzende intervallen met prijsniveau 'Zeer duur' op te nemen. Dit vangt extreem dure intervallen op aan de randen van gedetecteerde perioden.",
"peak_price_max_extension_intervals": "Maximaal aantal extra intervallen per kant (linker en rechter rand). Elk interval is 15 minuten. Voorbeeld: 4 intervallen = maximaal 1 uur uitbreiding per rand. Standaard: 4"
}
}
},
"submit": "↩ Opslaan & Terug"

View file

@ -238,6 +238,18 @@
"min_periods_best": "Minsta antal bästa prisperioder att sikta på per dag. Filter kommer att relaxeras steg för steg för att försöka uppnå detta antal. Endast aktiv när 'Uppnå Minimiantal' är aktiverad. Standard: 1",
"relaxation_attempts_best": "Hur många flexnivåer (försök) att prova innan man ger upp. Varje försök kör alla filterkombinationer på den nya flexnivån. Fler försök ökar chansen att hitta ytterligare perioder på bekostnad av längre behandlingstid."
}
},
"extension_settings": {
"name": "Utvidgning av periodändarna",
"description": "Utvidga eventuellt hittade bästa-prisperioder vid båda ändarna för att inkludera angränsande mycket billiga intervall.",
"data": {
"best_price_extend_to_very_cheap": "Utvidga till mycket billiga intervall",
"best_price_max_extension_intervals": "Maximalt antal utvidgningsintervall"
},
"data_description": {
"best_price_extend_to_very_cheap": "När aktiverat utvidgas hittade bästa-prisperioder utåt för att inkludera angränsande intervall med prisnivån 'Mycket billig'. Detta fångar upp extremt billiga intervall vid kanterna av hittade perioder.",
"best_price_max_extension_intervals": "Maximalt antal extra intervall per sida (vänster och höger kant). Varje intervall är 15 minuter. Exempel: 4 intervall = upp till 1 timmes utvidgning per kant. Standard: 4"
}
}
},
"submit": "↩ Spara & tillbaka"
@ -285,6 +297,18 @@
"min_periods_peak": "Minsta antal topprisperioder att sikta på per dag. Filter kommer att relaxeras steg för steg för att försöka uppnå detta antal. Endast aktiv när 'Uppnå Minimiantal' är aktiverad. Standard: 1",
"relaxation_attempts_peak": "Hur många flexnivåer (försök) att prova innan man ger upp. Varje försök kör alla filterkombinationer på den nya flexnivån. Fler försök ökar chansen att hitta ytterligare toppperioder på bekostnad av längre behandlingstid."
}
},
"extension_settings": {
"name": "Utvidgning av periodändarna",
"description": "Utvidga eventuellt hittade topprisperioder vid båda ändarna för att inkludera angränsande mycket dyra intervall.",
"data": {
"peak_price_extend_to_very_expensive": "Utvidga till mycket dyra intervall",
"peak_price_max_extension_intervals": "Maximalt antal utvidgningsintervall"
},
"data_description": {
"peak_price_extend_to_very_expensive": "När aktiverat utvidgas hittade topprisperioder utåt för att inkludera angränsande intervall med prisnivån 'Mycket dyr'. Detta fångar upp extremt dyra intervall vid kanterna av hittade perioder.",
"peak_price_max_extension_intervals": "Maximalt antal extra intervall per sida (vänster och höger kant). Varje intervall är 15 minuter. Exempel: 4 intervall = upp till 1 timmes utvidgning per kant. Standard: 4"
}
}
},
"submit": "↩ Spara & tillbaka"