feat(periods): geometric V-shape flex extension for period detection
Some checks are pending
Deploy Docusaurus Documentation (Dual Sites) / Build and Deploy Documentation Sites (push) Waiting to run
Lint / Ruff (push) Waiting to run
Validate / Hassfest validation (push) Waiting to run
Validate / HACS validation (push) Waiting to run

Uses valley/peak knee points from day pattern analysis to grant extra
flex to price intervals that fall inside detected geometric zones,
making period detection more permissive within V-shape (best price)
or Λ-shape (peak price) price formations.

New options:
- CONF_BEST_PRICE_GEOMETRIC_FLEX (0-25%, default 0 = disabled)
- CONF_PEAK_PRICE_GEOMETRIC_FLEX (0-25%, default 0 = disabled)

Implementation:
- compute_geometric_flex_bonus() in level_filtering.py checks if
  interval falls inside valley/peak zone and returns extra_flex
- period_building.py applies geo bonus per-interval via
  criteria._replace(flex=...) and sets geometric_bonus_applied flag
- period_statistics.py reports geometric_extension_active and
  geometric_extension_intervals in period summaries
- Day patterns threaded through full pipeline:
  data_transformation → coordinator/core → periods →
  relaxation → calculate_periods → price_context
- UI sliders in both extension_settings sections
- Translations: en, de, nb, nl, sv

Impact: Users with clearly V-shaped or Λ-shaped daily price curves
can enable geometric flex to improve period detection accuracy within
those characteristic shapes without increasing global flex.
This commit is contained in:
Julian Pawlowski 2026-04-11 21:49:24 +00:00
parent e44f639b41
commit 4ddd19b132
16 changed files with 230 additions and 45 deletions

View file

@ -14,6 +14,7 @@ from custom_components.tibber_prices.const import (
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,
@ -27,6 +28,7 @@ from custom_components.tibber_prices.const import (
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,
@ -55,6 +57,7 @@ from custom_components.tibber_prices.const import (
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,
@ -67,6 +70,7 @@ from custom_components.tibber_prices.const import (
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,
@ -96,6 +100,7 @@ from custom_components.tibber_prices.const import (
DISPLAY_MODE_SUBUNIT,
MAX_EXTENSION_INTERVALS,
MAX_GAP_COUNT,
MAX_GEOMETRIC_FLEX,
MAX_MIN_PERIOD_LENGTH,
MAX_MIN_PERIODS,
MAX_PRICE_LEVEL_GAP_TOLERANCE,
@ -649,6 +654,7 @@ def get_best_price_schema(
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 {}
@ -788,6 +794,18 @@ def get_best_price_schema(
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},
@ -839,6 +857,7 @@ def get_peak_price_schema(
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 {}
@ -978,6 +997,18 @@ def get_peak_price_schema(
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

@ -70,8 +70,10 @@ 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,8 +139,10 @@ DEFAULT_MIN_PERIODS_PEAK = 2 # Default: require at least 2 peak price periods (
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
@ -148,6 +152,7 @@ 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)
@ -420,8 +425,10 @@ def get_default_options(currency_code: str | None) -> dict[str, Any]:
"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

@ -875,9 +875,11 @@ class TibberPricesDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
"""Get threshold percentages from config options."""
return self._data_transformer.get_threshold_percentages()
def _calculate_periods_for_price_info(self, price_info: dict[str, Any]) -> dict[str, Any]:
def _calculate_periods_for_price_info(
self, price_info: dict[str, Any], day_patterns: dict[str, Any] | None = None
) -> dict[str, Any]:
"""Calculate periods (best price and peak price) for the given price info."""
return self._period_calculator.calculate_periods_for_price_info(price_info)
return self._period_calculator.calculate_periods_for_price_info(price_info, day_patterns)
def _transform_data(self, raw_data: dict[str, Any]) -> dict[str, Any]:
"""Transform raw data for main entry (aggregated view of all homes)."""

View file

@ -28,7 +28,7 @@ class TibberPricesDataTransformer:
self,
config_entry: ConfigEntry,
log_prefix: str,
calculate_periods_fn: Callable[[dict[str, Any]], dict[str, Any]],
calculate_periods_fn: Callable[[dict[str, Any], dict[str, Any] | None], dict[str, Any]],
time: TibberPricesTimeService,
) -> None:
"""Initialize the data transformer."""
@ -271,16 +271,19 @@ class TibberPricesDataTransformer:
"currency": currency,
}
# Calculate periods (best price and peak price)
if "priceInfo" in transformed_data:
transformed_data["pricePeriods"] = self._calculate_periods_fn(transformed_data["priceInfo"])
# 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")
)
# Cache the transformed data
self._cached_transformed_data = transformed_data
self._last_transformation_config = self._get_current_transformation_config()

View file

@ -38,6 +38,7 @@ def calculate_periods(
*,
config: TibberPricesPeriodConfig,
time: TibberPricesTimeService,
day_patterns_by_date: dict | None = None,
) -> dict[str, Any]:
"""
Calculate price periods (best or peak) from price data.
@ -59,6 +60,7 @@ def calculate_periods(
config: Period configuration containing reverse_sort, flex, min_distance_from_avg,
min_period_length, threshold_low, and threshold_high.
time: TibberPricesTimeService instance (required).
day_patterns_by_date: Optional dict mapping date day pattern dict for geometric flex bonus.
Returns:
Dict with:
@ -156,6 +158,8 @@ def calculate_periods(
"intervals_by_day": intervals_by_day, # Needed for day volatility calculation
"flex": flex,
"min_distance_from_avg": min_distance_from_avg,
"geometric_extra_flex": config.geometric_extra_flex, # Extra flex for geometric zone
"day_patterns_by_date": day_patterns_by_date, # Pattern data keyed by date (may be None)
}
raw_periods = build_periods(
all_prices_smoothed, # Use smoothed prices for period formation

View file

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

View file

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

View file

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

View file

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

View file

@ -58,6 +58,7 @@ class TibberPricesPeriodConfig(NamedTuple):
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):

View file

@ -8,6 +8,7 @@ gap tolerance, and coordination of the period_handlers calculation functions.
from __future__ import annotations
import logging
from datetime import date, timedelta
from typing import TYPE_CHECKING, Any
from custom_components.tibber_prices import const as _const
@ -260,6 +261,25 @@ class TibberPricesPeriodCalculator:
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
@ -633,6 +653,7 @@ class TibberPricesPeriodCalculator:
def calculate_periods_for_price_info(
self,
price_info: dict[str, Any],
day_patterns: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""
Calculate periods (best price and peak price) for the given price info.
@ -657,6 +678,19 @@ class TibberPricesPeriodCalculator:
coordinator_data = {"priceInfo": price_info}
all_prices = get_intervals_for_day_offsets(coordinator_data, [-2, -1, 0, 1])
# Convert day_patterns (keyed by "yesterday"/"today"/"tomorrow") to date-keyed dict
# Needed for geometric valley/peak zone flex bonus in period calculation
today_date = self.time.now().date()
day_patterns_by_date: dict[date, dict[str, Any]] | None = (
{
today_date + timedelta(days=ofs): pat
for ofs, lbl in ((-1, "yesterday"), (0, "today"), (1, "tomorrow"))
if (pat := day_patterns.get(lbl)) is not None
}
if day_patterns
else None
)
# Get rating thresholds from config (flat in options, not in sections)
# CRITICAL: Price rating thresholds are stored FLAT in options (no sections)
threshold_low = self.config_entry.options.get(
@ -739,6 +773,7 @@ class TibberPricesPeriodCalculator:
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,
@ -753,6 +788,7 @@ class TibberPricesPeriodCalculator:
),
time=self.time,
config_entry=self.config_entry,
day_patterns_by_date=day_patterns_by_date,
)
else:
best_periods = {
@ -822,6 +858,7 @@ class TibberPricesPeriodCalculator:
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,
@ -836,6 +873,7 @@ class TibberPricesPeriodCalculator:
),
time=self.time,
config_entry=self.config_entry,
day_patterns_by_date=day_patterns_by_date,
)
else:
peak_periods = {

View file

@ -244,11 +244,13 @@
"description": "Erkannte Bestpreisperioden optional an beiden Rändern erweitern, um angrenzende sehr günstige Intervalle aufzunehmen.",
"data": {
"best_price_extend_to_very_cheap": "Auf sehr günstige Intervalle erweitern",
"best_price_max_extension_intervals": "Maximale Erweiterungsintervalle"
"best_price_max_extension_intervals": "Maximale Erweiterungsintervalle",
"best_price_geometric_flex": "Geometrischer Flex-Bonus"
},
"data_description": {
"best_price_extend_to_very_cheap": "Wenn aktiviert, erweitern sich erkannte Bestpreisperioden nach außen, um angrenzende Intervalle mit dem Preisniveau 'Sehr günstig' aufzunehmen. So werden extrem günstige Intervalle an den Rändern erkannter Perioden besser erfasst.",
"best_price_max_extension_intervals": "Maximale Anzahl zusätzlicher Intervalle pro Seite (linker und rechter Rand). Jedes Intervall dauert 15 Minuten. Beispiel: 4 Intervalle = bis zu 1 Stunde Erweiterung pro Rand. Standard: 4"
"best_price_max_extension_intervals": "Maximale Anzahl zusätzlicher Intervalle pro Seite (linker und rechter Rand). Jedes Intervall dauert 15 Minuten. Beispiel: 4 Intervalle = bis zu 1 Stunde Erweiterung pro Rand. Standard: 4",
"best_price_geometric_flex": "Zusätzlicher Flex-Prozentsatz für Intervalle, die in ein erkanntes Preistal (V-Form) fallen. Wenn für den Tag ein Tal-Muster erkannt wird, erhalten Intervalle innerhalb der Talzone diese zusätzliche Toleranz, damit der Periodendetektor sie eher einschließt. 0 = deaktiviert. Standard: 0"
}
}
},
@ -303,11 +305,13 @@
"description": "Erkannte Spitzenpreisperioden optional an beiden Rändern erweitern, um angrenzende sehr teure Intervalle aufzunehmen.",
"data": {
"peak_price_extend_to_very_expensive": "Auf sehr teure Intervalle erweitern",
"peak_price_max_extension_intervals": "Maximale Erweiterungsintervalle"
"peak_price_max_extension_intervals": "Maximale Erweiterungsintervalle",
"peak_price_geometric_flex": "Geometrischer Flex-Bonus"
},
"data_description": {
"peak_price_extend_to_very_expensive": "Wenn aktiviert, erweitern sich erkannte Spitzenpreisperioden nach außen, um angrenzende Intervalle mit dem Preisniveau 'Sehr teuer' aufzunehmen. So werden extrem teure Intervalle an den Rändern erkannter Perioden besser erfasst.",
"peak_price_max_extension_intervals": "Maximale Anzahl zusätzlicher Intervalle pro Seite (linker und rechter Rand). Jedes Intervall dauert 15 Minuten. Beispiel: 4 Intervalle = bis zu 1 Stunde Erweiterung pro Rand. Standard: 4"
"peak_price_max_extension_intervals": "Maximale Anzahl zusätzlicher Intervalle pro Seite (linker und rechter Rand). Jedes Intervall dauert 15 Minuten. Beispiel: 4 Intervalle = bis zu 1 Stunde Erweiterung pro Rand. Standard: 4",
"peak_price_geometric_flex": "Zusätzlicher Flex-Prozentsatz für Intervalle, die in einen erkannten Preisplateau (Λ-Form) fallen. Wenn für den Tag ein Gipfel-Muster erkannt wird, erhalten Intervalle innerhalb der Gipfelzone diese zusätzliche Toleranz, damit der Periodendetektor sie eher einschließt. 0 = deaktiviert. Standard: 0"
}
}
},

View file

@ -255,11 +255,13 @@
"description": "Optionally extend detected best price periods at both edges to absorb adjacent very cheap intervals.",
"data": {
"best_price_extend_to_very_cheap": "Extend to Very Cheap Intervals",
"best_price_max_extension_intervals": "Maximum Extension Intervals"
"best_price_max_extension_intervals": "Maximum Extension Intervals",
"best_price_geometric_flex": "Geometric Flex Bonus"
},
"data_description": {
"best_price_extend_to_very_cheap": "When enabled, detected best price periods expand outward to absorb adjacent intervals with a 'Very cheap' price level. This widens low-price windows to better capture extremely cheap intervals at the edges of detected periods.",
"best_price_max_extension_intervals": "Maximum number of additional intervals to absorb per side (left and right edge). Each interval is 15 minutes. Example: 4 intervals = up to 1 hour extension per edge. Default: 4"
"best_price_max_extension_intervals": "Maximum number of additional intervals to absorb per side (left and right edge). Each interval is 15 minutes. Example: 4 intervals = up to 1 hour extension per edge. Default: 4",
"best_price_geometric_flex": "Extra flex percentage applied to intervals that fall inside a detected price valley (V-shape). When a valley pattern is detected for the day, intervals within the valley zone get this additional tolerance, making the period detector more likely to include them. 0 = disabled. Default: 0"
}
}
},
@ -314,11 +316,13 @@
"description": "Optionally extend detected peak price periods at both edges to absorb adjacent very expensive intervals.",
"data": {
"peak_price_extend_to_very_expensive": "Extend to Very Expensive Intervals",
"peak_price_max_extension_intervals": "Maximum Extension Intervals"
"peak_price_max_extension_intervals": "Maximum Extension Intervals",
"peak_price_geometric_flex": "Geometric Flex Bonus"
},
"data_description": {
"peak_price_extend_to_very_expensive": "When enabled, detected peak price periods expand outward to absorb adjacent intervals with a 'Very expensive' price level. This widens high-price windows to better capture extremely expensive intervals at the edges of detected periods.",
"peak_price_max_extension_intervals": "Maximum number of additional intervals to absorb per side (left and right edge). Each interval is 15 minutes. Example: 4 intervals = up to 1 hour extension per edge. Default: 4"
"peak_price_max_extension_intervals": "Maximum number of additional intervals to absorb per side (left and right edge). Each interval is 15 minutes. Example: 4 intervals = up to 1 hour extension per edge. Default: 4",
"peak_price_geometric_flex": "Extra flex percentage applied to intervals that fall inside a detected price peak (Λ-shape). When a peak pattern is detected for the day, intervals within the peak zone get this additional tolerance, making the period detector more likely to include them. 0 = disabled. Default: 0"
}
}
},

View file

@ -244,11 +244,13 @@
"description": "Utvid eventuelt oppdagede bestprisperioder ved begge ender for å inkludere tilstøtende svært billige intervaller.",
"data": {
"best_price_extend_to_very_cheap": "Utvid til svært billige intervaller",
"best_price_max_extension_intervals": "Maksimale utvidelsesintervaller"
"best_price_max_extension_intervals": "Maksimale utvidelsesintervaller",
"best_price_geometric_flex": "Geometrisk fleksbonus"
},
"data_description": {
"best_price_extend_to_very_cheap": "Når aktivert, utvider oppdagede bestprisperioder seg utover for å inkludere tilstøtende intervaller med prisnivået 'Svært billig'. Dette fanger opp ekstremt billige intervaller ved kantene av oppdagede perioder.",
"best_price_max_extension_intervals": "Maksimalt antall ekstra intervaller per side (venstre og høyre kant). Hvert intervall er 15 minutter. Eksempel: 4 intervaller = opptil 1 times utvidelse per kant. Standard: 4"
"best_price_max_extension_intervals": "Maksimalt antall ekstra intervaller per side (venstre og høyre kant). Hvert intervall er 15 minutter. Eksempel: 4 intervaller = opptil 1 times utvidelse per kant. Standard: 4",
"best_price_geometric_flex": "Ekstra fleksprosent for intervaller som faller innenfor en oppdaget prisdal (V-form). Når et dal-mønster oppdages for dagen, får intervaller innen dalsonen denne ekstra toleransen, slik at periodevarslingssystemet er mer tilbøyelig til å inkludere dem. 0 = deaktivert. Standard: 0"
}
}
},
@ -303,11 +305,13 @@
"description": "Utvid eventuelt oppdagede topprisperioder ved begge ender for å inkludere tilstøtende svært dyre intervaller.",
"data": {
"peak_price_extend_to_very_expensive": "Utvid til svært dyre intervaller",
"peak_price_max_extension_intervals": "Maksimale utvidelsesintervaller"
"peak_price_max_extension_intervals": "Maksimale utvidelsesintervaller",
"peak_price_geometric_flex": "Geometrisk fleksbonus"
},
"data_description": {
"peak_price_extend_to_very_expensive": "Når aktivert, utvider oppdagede topprisperioder seg utover for å inkludere tilstøtende intervaller med prisnivået 'Svært dyrt'. Dette fanger opp ekstremt dyre intervaller ved kantene av oppdagede perioder.",
"peak_price_max_extension_intervals": "Maksimalt antall ekstra intervaller per side (venstre og høyre kant). Hvert intervall er 15 minutter. Eksempel: 4 intervaller = opptil 1 times utvidelse per kant. Standard: 4"
"peak_price_max_extension_intervals": "Maksimalt antall ekstra intervaller per side (venstre og høyre kant). Hvert intervall er 15 minutter. Eksempel: 4 intervaller = opptil 1 times utvidelse per kant. Standard: 4",
"peak_price_geometric_flex": "Ekstra fleksprosent for intervaller som faller innenfor en oppdaget pristopp (Λ-form). Når et topp-mønster oppdages for dagen, får intervaller innen toppsonene denne ekstra toleransen, slik at periodevarslingssystemet er mer tilbøyelig til å inkludere dem. 0 = deaktivert. Standard: 0"
}
}
},

View file

@ -244,11 +244,13 @@
"description": "Breid gedetecteerde beste-prijsperioden eventueel uit aan beide randen om aangrenzende zeer goedkope intervallen op te nemen.",
"data": {
"best_price_extend_to_very_cheap": "Uitbreiden met zeer goedkope intervallen",
"best_price_max_extension_intervals": "Maximale uitbreidingsintervallen"
"best_price_max_extension_intervals": "Maximale uitbreidingsintervallen",
"best_price_geometric_flex": "Geometrische Flex Bonus"
},
"data_description": {
"best_price_extend_to_very_cheap": "Indien ingeschakeld, breiden gedetecteerde beste-prijsperioden zich uit aan de randen om aangrenzende intervallen met prijsniveau 'Zeer goedkoop' op te nemen. Dit vangt extreem goedkope intervallen op aan de randen van gedetecteerde perioden.",
"best_price_max_extension_intervals": "Maximaal aantal extra intervallen per kant (linker en rechter rand). Elk interval is 15 minuten. Voorbeeld: 4 intervallen = maximaal 1 uur uitbreiding per rand. Standaard: 4"
"best_price_max_extension_intervals": "Maximaal aantal extra intervallen per kant (linker en rechter rand). Elk interval is 15 minuten. Voorbeeld: 4 intervallen = maximaal 1 uur uitbreiding per rand. Standaard: 4",
"best_price_geometric_flex": "Extra flex percentage voor intervallen die binnen een gedetecteerde prijsdal (V-vorm) vallen. Wanneer een dal-patroon wordt gedetecteerd voor de dag, krijgen intervallen binnen de dalzone deze extra tolerantie, waardoor de periodedetector ze eerder opneemt. 0 = uitgeschakeld. Standaard: 0"
}
}
},
@ -303,11 +305,13 @@
"description": "Breid gedetecteerde piekprijsperioden eventueel uit aan beide randen om aangrenzende zeer dure intervallen op te nemen.",
"data": {
"peak_price_extend_to_very_expensive": "Uitbreiden met zeer dure intervallen",
"peak_price_max_extension_intervals": "Maximale uitbreidingsintervallen"
"peak_price_max_extension_intervals": "Maximale uitbreidingsintervallen",
"peak_price_geometric_flex": "Geometrische Flex Bonus"
},
"data_description": {
"peak_price_extend_to_very_expensive": "Indien ingeschakeld, breiden gedetecteerde piekprijsperioden zich uit aan de randen om aangrenzende intervallen met prijsniveau 'Zeer duur' op te nemen. Dit vangt extreem dure intervallen op aan de randen van gedetecteerde perioden.",
"peak_price_max_extension_intervals": "Maximaal aantal extra intervallen per kant (linker en rechter rand). Elk interval is 15 minuten. Voorbeeld: 4 intervallen = maximaal 1 uur uitbreiding per rand. Standaard: 4"
"peak_price_max_extension_intervals": "Maximaal aantal extra intervallen per kant (linker en rechter rand). Elk interval is 15 minuten. Voorbeeld: 4 intervallen = maximaal 1 uur uitbreiding per rand. Standaard: 4",
"peak_price_geometric_flex": "Extra flex percentage voor intervallen die binnen een gedetecteerde prijspiek (Λ-vorm) vallen. Wanneer een piek-patroon wordt gedetecteerd voor de dag, krijgen intervallen binnen de piekzone deze extra tolerantie, waardoor de periodedetector ze eerder opneemt. 0 = uitgeschakeld. Standaard: 0"
}
}
},

View file

@ -244,11 +244,13 @@
"description": "Utvidga eventuellt hittade bästa-prisperioder vid båda ändarna för att inkludera angränsande mycket billiga intervall.",
"data": {
"best_price_extend_to_very_cheap": "Utvidga till mycket billiga intervall",
"best_price_max_extension_intervals": "Maximalt antal utvidgningsintervall"
"best_price_max_extension_intervals": "Maximalt antal utvidgningsintervall",
"best_price_geometric_flex": "Geometrisk flexbonus"
},
"data_description": {
"best_price_extend_to_very_cheap": "När aktiverat utvidgas hittade bästa-prisperioder utåt för att inkludera angränsande intervall med prisnivån 'Mycket billig'. Detta fångar upp extremt billiga intervall vid kanterna av hittade perioder.",
"best_price_max_extension_intervals": "Maximalt antal extra intervall per sida (vänster och höger kant). Varje intervall är 15 minuter. Exempel: 4 intervall = upp till 1 timmes utvidgning per kant. Standard: 4"
"best_price_max_extension_intervals": "Maximalt antal extra intervall per sida (vänster och höger kant). Varje intervall är 15 minuter. Exempel: 4 intervall = upp till 1 timmes utvidgning per kant. Standard: 4",
"best_price_geometric_flex": "Extra flexprocentandel för intervall som faller inom en detekterad prisdal (V-form). När ett dalmönster detekteras för dagen får intervall inom dalzonen denna extra tolerans, vilket gör att perioddektorn är mer benägen att inkludera dem. 0 = inaktiverad. Standard: 0"
}
}
},
@ -303,11 +305,13 @@
"description": "Utvidga eventuellt hittade topprisperioder vid båda ändarna för att inkludera angränsande mycket dyra intervall.",
"data": {
"peak_price_extend_to_very_expensive": "Utvidga till mycket dyra intervall",
"peak_price_max_extension_intervals": "Maximalt antal utvidgningsintervall"
"peak_price_max_extension_intervals": "Maximalt antal utvidgningsintervall",
"peak_price_geometric_flex": "Geometrisk flexbonus"
},
"data_description": {
"peak_price_extend_to_very_expensive": "När aktiverat utvidgas hittade topprisperioder utåt för att inkludera angränsande intervall med prisnivån 'Mycket dyr'. Detta fångar upp extremt dyra intervall vid kanterna av hittade perioder.",
"peak_price_max_extension_intervals": "Maximalt antal extra intervall per sida (vänster och höger kant). Varje intervall är 15 minuter. Exempel: 4 intervall = upp till 1 timmes utvidgning per kant. Standard: 4"
"peak_price_max_extension_intervals": "Maximalt antal extra intervall per sida (vänster och höger kant). Varje intervall är 15 minuter. Exempel: 4 intervall = upp till 1 timmes utvidgning per kant. Standard: 4",
"peak_price_geometric_flex": "Extra flexprocentandel för intervall som faller inom en detekterad prispeak (Λ-form). När ett peak-mönster detekteras för dagen får intervall inom peakzonen denna extra tolerans, vilket gör att perioddetektor är mer benägen att inkludera dem. 0 = inaktiverad. Standard: 0"
}
}
},