feat(relaxation): make tail handling smarter and attempts configurable

- Skip asymmetry/zigzag rejection near the data tail and refactor spike
  validation so legitimate end-of-day spikes stop breaking periods.
- Expose relaxation attempt sliders for both Best/Peak flows, wire the values
  through the coordinator, and extend the relaxation engine to honor the new
  max-attempt cap with richer logging & metadata.
- Raise the default attempt count to eight flex levels so the 25% increment
  pattern can stretch much further before stopping, keeping translations and
  docs (including the matrix explanation) in sync across all locales.

Impact: Tail spikes no longer get thrown out incorrectly, users can tune how
aggressively the period search relaxes, and the defaults now find more viable
periods on volatile days.
This commit is contained in:
Julian Pawlowski 2025-11-14 00:07:12 +00:00
parent d3c02568ee
commit 5a5c8ca3cc
11 changed files with 233 additions and 78 deletions

View file

@ -59,6 +59,8 @@ from .const import (
CONF_PRICE_RATING_THRESHOLD_LOW,
CONF_PRICE_TREND_THRESHOLD_FALLING,
CONF_PRICE_TREND_THRESHOLD_RISING,
CONF_RELAXATION_ATTEMPTS_BEST,
CONF_RELAXATION_ATTEMPTS_PEAK,
CONF_RELAXATION_STEP_BEST,
CONF_RELAXATION_STEP_PEAK,
CONF_VOLATILITY_THRESHOLD_HIGH,
@ -83,6 +85,8 @@ from .const import (
DEFAULT_PRICE_RATING_THRESHOLD_LOW,
DEFAULT_PRICE_TREND_THRESHOLD_FALLING,
DEFAULT_PRICE_TREND_THRESHOLD_RISING,
DEFAULT_RELAXATION_ATTEMPTS_BEST,
DEFAULT_RELAXATION_ATTEMPTS_PEAK,
DEFAULT_RELAXATION_STEP_BEST,
DEFAULT_RELAXATION_STEP_PEAK,
DEFAULT_VOLATILITY_THRESHOLD_HIGH,
@ -721,6 +725,22 @@ class TibberPricesOptionsFlowHandler(OptionsFlow):
mode=NumberSelectorMode.SLIDER,
),
),
vol.Optional(
CONF_RELAXATION_ATTEMPTS_BEST,
default=int(
self.config_entry.options.get(
CONF_RELAXATION_ATTEMPTS_BEST,
DEFAULT_RELAXATION_ATTEMPTS_BEST,
)
),
): NumberSelector(
NumberSelectorConfig(
min=1,
max=12,
step=1,
mode=NumberSelectorMode.SLIDER,
),
),
}
),
description_placeholders=self._get_step_description_placeholders("best_price"),
@ -856,6 +876,22 @@ class TibberPricesOptionsFlowHandler(OptionsFlow):
mode=NumberSelectorMode.SLIDER,
),
),
vol.Optional(
CONF_RELAXATION_ATTEMPTS_PEAK,
default=int(
self.config_entry.options.get(
CONF_RELAXATION_ATTEMPTS_PEAK,
DEFAULT_RELAXATION_ATTEMPTS_PEAK,
)
),
): NumberSelector(
NumberSelectorConfig(
min=1,
max=12,
step=1,
mode=NumberSelectorMode.SLIDER,
),
),
}
),
description_placeholders=self._get_step_description_placeholders("peak_price"),

View file

@ -38,9 +38,11 @@ CONF_PEAK_PRICE_MAX_LEVEL_GAP_COUNT = "peak_price_max_level_gap_count"
CONF_ENABLE_MIN_PERIODS_BEST = "enable_min_periods_best"
CONF_MIN_PERIODS_BEST = "min_periods_best"
CONF_RELAXATION_STEP_BEST = "relaxation_step_best"
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_STEP_PEAK = "relaxation_step_peak"
CONF_RELAXATION_ATTEMPTS_PEAK = "relaxation_attempts_peak"
ATTRIBUTION = "Data provided by Tibber"
@ -78,9 +80,11 @@ MIN_INTERVALS_FOR_GAP_TOLERANCE = 6 # Minimum period length (in 15-min interval
DEFAULT_ENABLE_MIN_PERIODS_BEST = True # Default: minimum periods feature enabled for best price
DEFAULT_MIN_PERIODS_BEST = 2 # Default: require at least 2 best price periods (when enabled)
DEFAULT_RELAXATION_STEP_BEST = 25 # Default: 25% of original threshold per relaxation step for best price
DEFAULT_RELAXATION_ATTEMPTS_BEST = 8 # Default: try 8 flex levels during relaxation (best price)
DEFAULT_ENABLE_MIN_PERIODS_PEAK = True # Default: minimum periods feature enabled for peak price
DEFAULT_MIN_PERIODS_PEAK = 2 # Default: require at least 2 peak price periods (when enabled)
DEFAULT_RELAXATION_STEP_PEAK = 25 # Default: 25% of original threshold per relaxation step for peak price
DEFAULT_RELAXATION_ATTEMPTS_PEAK = 8 # Default: try 8 flex levels during relaxation (peak price)
# Home types
HOME_TYPE_APARTMENT = "APARTMENT"

View file

@ -41,6 +41,8 @@ from .const import (
CONF_PEAK_PRICE_MIN_PERIOD_LENGTH,
CONF_PRICE_RATING_THRESHOLD_HIGH,
CONF_PRICE_RATING_THRESHOLD_LOW,
CONF_RELAXATION_ATTEMPTS_BEST,
CONF_RELAXATION_ATTEMPTS_PEAK,
CONF_RELAXATION_STEP_BEST,
CONF_RELAXATION_STEP_PEAK,
CONF_VOLATILITY_THRESHOLD_HIGH,
@ -62,6 +64,8 @@ from .const import (
DEFAULT_PEAK_PRICE_MIN_PERIOD_LENGTH,
DEFAULT_PRICE_RATING_THRESHOLD_HIGH,
DEFAULT_PRICE_RATING_THRESHOLD_LOW,
DEFAULT_RELAXATION_ATTEMPTS_BEST,
DEFAULT_RELAXATION_ATTEMPTS_PEAK,
DEFAULT_RELAXATION_STEP_BEST,
DEFAULT_RELAXATION_STEP_PEAK,
DEFAULT_VOLATILITY_THRESHOLD_HIGH,
@ -1166,6 +1170,10 @@ class TibberPricesDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
CONF_RELAXATION_STEP_BEST,
DEFAULT_RELAXATION_STEP_BEST,
)
relaxation_attempts_best = self.config_entry.options.get(
CONF_RELAXATION_ATTEMPTS_BEST,
DEFAULT_RELAXATION_ATTEMPTS_BEST,
)
# Calculate best price periods (or return empty if filtered)
if show_best_price:
@ -1198,6 +1206,7 @@ class TibberPricesDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
enable_relaxation=enable_relaxation_best,
min_periods=min_periods_best,
relaxation_step_pct=relaxation_step_best,
max_relaxation_attempts=relaxation_attempts_best,
should_show_callback=lambda _vol, lvl: self._should_show_periods(
price_info,
reverse_sort=False,
@ -1233,6 +1242,10 @@ class TibberPricesDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
CONF_RELAXATION_STEP_PEAK,
DEFAULT_RELAXATION_STEP_PEAK,
)
relaxation_attempts_peak = self.config_entry.options.get(
CONF_RELAXATION_ATTEMPTS_PEAK,
DEFAULT_RELAXATION_ATTEMPTS_PEAK,
)
# Calculate peak price periods (or return empty if filtered)
if show_peak_price:
@ -1265,6 +1278,7 @@ class TibberPricesDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
enable_relaxation=enable_relaxation_peak,
min_periods=min_periods_peak,
relaxation_step_pct=relaxation_step_peak,
max_relaxation_attempts=relaxation_attempts_peak,
should_show_callback=lambda _vol, lvl: self._should_show_periods(
price_info,
reverse_sort=True,

View file

@ -15,6 +15,7 @@ Uses statistical methods:
from __future__ import annotations
import logging
from dataclasses import dataclass
_LOGGER = logging.getLogger(__name__)
@ -24,11 +25,45 @@ CONFIDENCE_LEVEL = 2.0 # Standard deviations for 95% confidence interval
VOLATILITY_THRESHOLD = 0.05 # 5% max relative std dev for zigzag detection
SYMMETRY_THRESHOLD = 1.5 # Max std dev difference for symmetric spike
RELATIVE_VOLATILITY_THRESHOLD = 2.0 # Window volatility vs context (cluster detection)
ASYMMETRY_TAIL_WINDOW = 6 # Skip asymmetry check for last ~1.5h (6 intervals) of available data
ZIGZAG_TAIL_WINDOW = 6 # Skip zigzag/cluster detection for last ~1.5h (6 intervals)
# Module-local log indentation (each module starts at level 0)
INDENT_L0 = "" # All logs in this module (no indentation needed)
@dataclass(slots=True)
class SpikeCandidateContext:
"""Container for spike validation parameters."""
current: dict
context_before: list[dict]
context_after: list[dict]
flexibility_ratio: float
remaining_intervals: int
stats: dict[str, float]
analysis_window: list[dict]
def _should_skip_tail_check(
remaining_intervals: int,
tail_window: int,
check_name: str,
interval_label: str,
) -> bool:
"""Return True when remaining intervals fall inside tail window and log why."""
if remaining_intervals < tail_window:
_LOGGER.debug(
"%sSpike at %s: Skipping %s check (only %d intervals remaining)",
INDENT_L0,
interval_label,
check_name,
remaining_intervals,
)
return True
return False
def _calculate_statistics(prices: list[float]) -> dict[str, float]:
"""
Calculate statistical measures for price context.
@ -147,6 +182,57 @@ def _detect_zigzag_pattern(window: list[dict], context_std_dev: float) -> bool:
return std_dev > RELATIVE_VOLATILITY_THRESHOLD * context_std_dev
def _validate_spike_candidate(
candidate: SpikeCandidateContext,
) -> bool:
"""Run stability, symmetry, and zigzag checks before smoothing."""
avg_before = sum(x["total"] for x in candidate.context_before) / len(candidate.context_before)
avg_after = sum(x["total"] for x in candidate.context_after) / len(candidate.context_after)
context_diff_pct = abs(avg_after - avg_before) / avg_before if avg_before > 0 else 0
if context_diff_pct > candidate.flexibility_ratio:
_LOGGER.debug(
"%sInterval %s: Context unstable (%.1f%% change) - not a spike",
INDENT_L0,
candidate.current.get("startsAt", "unknown interval"),
context_diff_pct * 100,
)
return False
if not _should_skip_tail_check(
candidate.remaining_intervals,
ASYMMETRY_TAIL_WINDOW,
"asymmetry",
candidate.current.get("startsAt", "unknown interval"),
) and not _check_symmetry(avg_before, avg_after, candidate.stats["std_dev"]):
_LOGGER.debug(
"%sSpike at %s rejected: Asymmetric (before=%.2f, after=%.2f ct/kWh)",
INDENT_L0,
candidate.current.get("startsAt", "unknown interval"),
avg_before * 100,
avg_after * 100,
)
return False
if _should_skip_tail_check(
candidate.remaining_intervals,
ZIGZAG_TAIL_WINDOW,
"zigzag/cluster",
candidate.current.get("startsAt", "unknown interval"),
):
return True
if _detect_zigzag_pattern(candidate.analysis_window, candidate.stats["std_dev"]):
_LOGGER.debug(
"%sSpike at %s rejected: Zigzag/cluster pattern detected",
INDENT_L0,
candidate.current.get("startsAt", "unknown interval"),
)
return False
return True
def filter_price_outliers(
intervals: list[dict],
flexibility_pct: float,
@ -220,48 +306,20 @@ def filter_price_outliers(
continue
# SPIKE CANDIDATE DETECTED - Now validate
# Check 1: Context Stability
# If context is changing significantly, this might be a legitimate transition
avg_before = sum(x["total"] for x in context_before) / len(context_before)
avg_after = sum(x["total"] for x in context_after) / len(context_after)
context_diff_pct = abs(avg_after - avg_before) / avg_before if avg_before > 0 else 0
if context_diff_pct > flexibility_ratio:
result.append(current)
_LOGGER.debug(
"%sInterval %s: Context unstable (%.1f%% change) - not a spike",
INDENT_L0,
current.get("startsAt", f"index {i}"),
context_diff_pct * 100,
)
continue
# Check 2: Symmetry
# Symmetric spikes return to baseline; asymmetric might be legitimate shifts
if not _check_symmetry(avg_before, avg_after, stats["std_dev"]):
result.append(current)
_LOGGER.debug(
"%sSpike at %s rejected: Asymmetric (before=%.2f, after=%.2f ct/kWh)",
INDENT_L0,
current.get("startsAt", f"index {i}"),
avg_before * 100,
avg_after * 100,
)
continue
# Check 3: Zigzag Pattern / Cluster Detection
# Build analysis window including the spike
remaining_intervals = len(intervals) - (i + 1)
analysis_window = [*context_before[-2:], current, *context_after[:2]]
if _detect_zigzag_pattern(analysis_window, stats["std_dev"]):
result.append(current)
_LOGGER.debug(
"%sSpike at %s rejected: Zigzag/cluster pattern detected",
INDENT_L0,
current.get("startsAt", f"index {i}"),
candidate_context = SpikeCandidateContext(
current=current,
context_before=context_before,
context_after=context_after,
flexibility_ratio=flexibility_ratio,
remaining_intervals=remaining_intervals,
stats=stats,
analysis_window=analysis_window,
)
if not _validate_spike_candidate(candidate_context):
result.append(current)
continue
# ALL CHECKS PASSED - Smooth the spike

View file

@ -166,6 +166,7 @@ def calculate_periods_with_relaxation( # noqa: PLR0913, PLR0915 - Per-day relax
enable_relaxation: bool,
min_periods: int,
relaxation_step_pct: int,
max_relaxation_attempts: int,
should_show_callback: Callable[[str | None, str | None], bool],
) -> tuple[dict[str, Any], dict[str, Any]]:
"""
@ -186,10 +187,13 @@ def calculate_periods_with_relaxation( # noqa: PLR0913, PLR0915 - Per-day relax
config: Base period configuration
enable_relaxation: Whether relaxation is enabled
min_periods: Minimum number of periods required PER DAY
relaxation_step_pct: Percentage of original flex to add per relaxation step
relaxation_step_pct: Percentage of the original flex threshold to add per relaxation
step (controls how aggressively flex widens with each attempt)
max_relaxation_attempts: Maximum number of flex levels (attempts) to try per day
before giving up (each attempt runs the full filter matrix)
should_show_callback: Callback function(volatility_override, level_override) -> bool
Returns True if periods should be shown with given filter overrides.
Pass None to use original configured filter values.
Returns True if periods should be shown with given filter overrides. Pass None
to use original configured filter values.
Returns:
Tuple of (periods_result, relaxation_metadata):
@ -246,9 +250,10 @@ def calculate_periods_with_relaxation( # noqa: PLR0913, PLR0915 - Per-day relax
min_periods,
)
_LOGGER.debug(
"%sRelaxation strategy: %.1f%% flex increment per step (4 flex levels x 4 filter combinations)",
"%sRelaxation strategy: %.1f%% flex increment per step (%d flex levels x 4 filter combinations)",
INDENT_L0,
relaxation_step_pct,
max_relaxation_attempts,
)
_LOGGER.debug(
"%sEarly exit: After EACH filter combination when target reached",
@ -334,6 +339,7 @@ def calculate_periods_with_relaxation( # noqa: PLR0913, PLR0915 - Per-day relax
config=config,
min_periods=min_periods,
relaxation_step_pct=relaxation_step_pct,
max_relaxation_attempts=max_relaxation_attempts,
should_show_callback=should_show_callback,
baseline_periods=day_periods,
day_label=str(day),
@ -381,6 +387,7 @@ def relax_single_day( # noqa: PLR0913 - Comprehensive filter relaxation per day
config: PeriodConfig,
min_periods: int,
relaxation_step_pct: int,
max_relaxation_attempts: int,
should_show_callback: Callable[[str | None, str | None], bool],
baseline_periods: list[dict],
day_label: str,
@ -406,6 +413,7 @@ def relax_single_day( # noqa: PLR0913 - Comprehensive filter relaxation per day
config: Base period configuration
min_periods: Minimum periods needed for this day
relaxation_step_pct: Relaxation increment percentage
max_relaxation_attempts: Maximum number of flex levels (attempts) to try for this day
should_show_callback: Filter visibility callback(volatility_override, level_override)
Returns True if periods should be shown with given overrides.
baseline_periods: Periods found with normal filters
@ -428,8 +436,10 @@ def relax_single_day( # noqa: PLR0913 - Comprehensive filter relaxation per day
baseline_standalone = len([p for p in baseline_periods if not p.get("is_extension")])
# 4 flex levels: original + 3 steps (e.g., 5% → 6.25% → 7.5% → 8.75% → 10%)
for flex_step in range(1, 5):
attempts = max(1, max_relaxation_attempts)
# Flex levels: original + N steps (e.g., 5% → 6.25% → ...)
for flex_step in range(1, attempts + 1):
new_flex = original_flex + (flex_step * relaxation_increment)
new_flex = min(new_flex, 100.0)

View file

@ -108,14 +108,16 @@
"best_price_max_level_gap_count": "Lückentoleranz für Niveaufilter",
"enable_min_periods_best": "Mindestanzahl Perioden anstreben",
"min_periods_best": "Mindestanzahl Perioden",
"relaxation_step_best": "Lockerungsschritt"
"relaxation_step_best": "Lockerungsschritt",
"relaxation_attempts_best": "Lockerungsversuche (Flex-Stufen)"
},
"data_description": {
"best_price_max_level": "Zeigt Bestpreis-Perioden nur an, wenn sie Intervalle mit Preisniveaus ≤ dem gewählten Wert enthalten. Beispiel: Wahl von 'Günstig' bedeutet, dass die Periode mindestens ein 'SEHR_GÜNSTIG' oder 'GÜNSTIG' Intervall haben muss. Dies stellt sicher, dass 'Bestpreis'-Perioden nicht nur relativ günstig für den Tag sind, sondern tatsächlich günstig in absoluten Zahlen. Wähle 'Beliebig' um Bestpreise unabhängig vom absoluten Preisniveau anzuzeigen.",
"best_price_max_level_gap_count": "Maximale Anzahl aufeinanderfolgender Intervalle, die exakt um eine Niveaustufe vom geforderten Level abweichen dürfen. Beispiel: Bei Filter 'Günstig' und Lückentoleranz 1 wird die Sequenz 'GÜNSTIG, GÜNSTIG, NORMAL, GÜNSTIG' akzeptiert (NORMAL ist eine Stufe über GÜNSTIG). Dies verhindert, dass Perioden durch gelegentliche Niveau-Abweichungen aufgespalten werden. Standard: 0 (strenge Filterung, keine Toleranz).",
"enable_min_periods_best": "Wenn aktiviert, werden Filter schrittweise gelockert, falls nicht genug Perioden gefunden wurden. Dies versucht die gewünschte Mindestanzahl zu erreichen, was dazu führen kann, dass auch weniger optimale Zeiträume als Bestpreis-Perioden markiert werden.",
"min_periods_best": "Mindestanzahl an Bestpreis-Perioden, die pro Tag angestrebt werden. Filter werden schrittweise gelockert, um diese Anzahl zu erreichen. Nur aktiv, wenn 'Mindestanzahl Perioden anstreben' aktiviert ist. Standard: 1",
"relaxation_step_best": "Prozentsatz des ursprünglichen Flexibilitätsschwellwerts, der pro Lockerungsschritt addiert wird. Beispiel: Bei 15% Flexibilität und 25% Schrittgröße werden 15%, 18,75%, 22,5% usw. versucht. Höhere Werte bedeuten schnellere Lockerung, aber geringere Präzision."
"relaxation_step_best": "Prozentsatz des ursprünglichen Flexibilitätsschwellwerts, der pro Lockerungsschritt addiert wird. Beispiel: Bei 15% Flexibilität und 25% Schrittgröße werden 15%, 18,75%, 22,5% usw. versucht. Höhere Werte bedeuten schnellere Lockerung, aber geringere Präzision.",
"relaxation_attempts_best": "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 Perioden, benötigen aber etwas mehr Rechenzeit."
},
"submit": "Weiter zu Schritt 5"
},
@ -130,14 +132,16 @@
"peak_price_max_level_gap_count": "Lückentoleranz für Niveaufilter",
"enable_min_periods_peak": "Mindestanzahl Perioden anstreben",
"min_periods_peak": "Mindestanzahl Perioden",
"relaxation_step_peak": "Lockerungsschritt"
"relaxation_step_peak": "Lockerungsschritt",
"relaxation_attempts_peak": "Lockerungsversuche (Flex-Stufen)"
},
"data_description": {
"peak_price_min_level": "Zeigt Spitzenpreis-Perioden nur an, wenn sie Intervalle mit Preisniveaus ≥ dem gewählten Wert enthalten. Beispiel: Wahl von 'Teuer' bedeutet, dass die Periode mindestens ein 'TEUER' oder 'SEHR_TEUER' Intervall haben muss. Dies stellt sicher, dass 'Spitzenpreis'-Perioden nicht nur relativ teuer für den Tag sind, sondern tatsächlich teuer in absoluten Zahlen. Wähle 'Beliebig' um Spitzenpreise unabhängig vom absoluten Preisniveau anzuzeigen.",
"peak_price_max_level_gap_count": "Maximale Anzahl aufeinanderfolgender Intervalle, die exakt um eine Niveaustufe vom geforderten Level abweichen dürfen. Beispiel: Bei Filter 'Teuer' und Lückentoleranz 2 wird die Sequenz 'TEUER, NORMAL, NORMAL, TEUER' akzeptiert (NORMAL ist eine Stufe unter TEUER). Dies verhindert, dass Perioden durch gelegentliche Niveau-Abweichungen aufgespalten werden. Standard: 0 (strenge Filterung, keine Toleranz).",
"enable_min_periods_peak": "Wenn aktiviert, werden Filter schrittweise gelockert, falls nicht genug Perioden gefunden wurden. Dies versucht die gewünschte Mindestanzahl zu erreichen, um sicherzustellen, dass du auch an Tagen mit ungewöhnlichen Preismustern vor teuren Perioden gewarnt wirst.",
"min_periods_peak": "Mindestanzahl an Spitzenpreis-Perioden, die pro Tag angestrebt werden. Filter werden schrittweise gelockert, um diese Anzahl zu erreichen. Nur aktiv, wenn 'Mindestanzahl Perioden anstreben' aktiviert ist. Standard: 1",
"relaxation_step_peak": "Prozentsatz des ursprünglichen Flexibilitätsschwellwerts, der pro Lockerungsschritt addiert wird. Beispiel: Bei -15% Flexibilität und 25% Schrittgröße werden -15%, -18,75%, -22,5% usw. versucht. Höhere Werte bedeuten schnellere Lockerung, aber geringere Präzision."
"relaxation_step_peak": "Prozentsatz des ursprünglichen Flexibilitätsschwellwerts, der pro Lockerungsschritt addiert wird. Beispiel: Bei -15% Flexibilität und 25% Schrittgröße werden -15%, -18,75%, -22,5% usw. versucht. Höhere Werte bedeuten schnellere Lockerung, aber geringere Präzision.",
"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-Perioden, benötigen aber etwas mehr Rechenzeit."
},
"submit": "Weiter zu Schritt 6"
},

View file

@ -108,14 +108,16 @@
"best_price_max_level_gap_count": "Level Filter Gap Tolerance",
"enable_min_periods_best": "Try to Achieve Minimum Period Count",
"min_periods_best": "Minimum Periods Required",
"relaxation_step_best": "Filter Relaxation Step Size"
"relaxation_step_best": "Filter Relaxation Step Size",
"relaxation_attempts_best": "Relaxation Attempts"
},
"data_description": {
"best_price_max_level": "Only show best price periods if they contain intervals with price levels ≤ selected value. For example, selecting 'Cheap' means the period must have at least one 'VERY_CHEAP' or 'CHEAP' interval. This ensures 'best price' periods are not just relatively cheap for the day, but actually cheap in absolute terms. Select 'Any' to show best prices regardless of their absolute price level.",
"best_price_max_level_gap_count": "Maximum number of consecutive intervals allowed that deviate by exactly one level step from the required level. For example: with 'Cheap' filter and gap count 1, a sequence 'CHEAP, CHEAP, NORMAL, CHEAP' is accepted (NORMAL is one step above CHEAP). This prevents periods from being split by occasional level deviations. Default: 0 (strict filtering, no tolerance).",
"enable_min_periods_best": "When enabled, filters will be gradually relaxed if not enough periods are found. This attempts to reach the desired minimum number of periods, which may include less optimal time windows as best-price periods.",
"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 'Try to Achieve Minimum Period Count' is enabled. Default: 1",
"relaxation_step_best": "Percentage of the original flexibility threshold to add per relaxation step. For example: with 15% flexibility and 25% step size, filters will try 15%, 18.75%, 22.5%, etc. Higher values mean faster relaxation but less precision."
"relaxation_step_best": "Percentage of the original flexibility threshold to add per relaxation step. For example: with 15% flexibility and 25% step size, filters will try 15%, 18.75%, 22.5%, etc. Higher values mean faster relaxation but less precision.",
"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."
},
"submit": "Next to Step 5"
},
@ -130,14 +132,16 @@
"peak_price_max_level_gap_count": "Level Filter Gap Tolerance",
"enable_min_periods_peak": "Try to Achieve Minimum Period Count",
"min_periods_peak": "Minimum Periods Required",
"relaxation_step_peak": "Filter Relaxation Step Size"
"relaxation_step_peak": "Filter Relaxation Step Size",
"relaxation_attempts_peak": "Relaxation Attempts"
},
"data_description": {
"peak_price_min_level": "Only show peak price periods if they contain intervals with price levels ≥ selected value. For example, selecting 'Expensive' means the period must have at least one 'EXPENSIVE' or 'VERY_EXPENSIVE' interval. This ensures 'peak price' periods are not just relatively expensive for the day, but actually expensive in absolute terms. Select 'Any' to show peak prices regardless of their absolute price level.",
"peak_price_max_level_gap_count": "Maximum number of consecutive intervals allowed that deviate by exactly one level step from the required level. For example: with 'Expensive' filter and gap count 2, a sequence 'EXPENSIVE, NORMAL, NORMAL, EXPENSIVE' is accepted (NORMAL is one step below EXPENSIVE). This prevents periods from being split by occasional level deviations. Default: 0 (strict filtering, no tolerance).",
"enable_min_periods_peak": "When enabled, filters will be gradually relaxed if not enough periods are found. This attempts to reach the desired minimum number of periods to ensure you're warned about expensive periods even on days with unusual price patterns.",
"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 'Try to Achieve Minimum Period Count' is enabled. Default: 1",
"relaxation_step_peak": "Percentage of the original flexibility threshold to add per relaxation step. For example: with -15% flexibility and 25% step size, filters will try -15%, -18.75%, -22.5%, etc. Higher values mean faster relaxation but less precision."
"relaxation_step_peak": "Percentage of the original flexibility threshold to add per relaxation step. For example: with -15% flexibility and 25% step size, filters will try -15%, -18.75%, -22.5%, etc. Higher values mean faster relaxation but less precision.",
"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."
},
"submit": "Next to Step 6"
},

View file

@ -108,13 +108,15 @@
"best_price_max_level_gap_count": "Gaptoleranse for nivåfilter",
"enable_min_periods_best": "Prøv å oppnå minimum antall perioder",
"min_periods_best": "Minimum antall perioder",
"relaxation_step_best": "Avslappingstrinn"
"relaxation_step_best": "Avslappingstrinn",
"relaxation_attempts_best": "Antall forsøk (fleksnivåer)"
},
"data_description": {
"best_price_max_level": "Vis kun beste prisperioder hvis de inneholder intervaller med prisnivåer ≤ valgt verdi. For eksempel: å velge 'Billig' betyr at perioden må ha minst ett 'VELDIG_BILLIG' eller 'BILLIG' intervall. Dette sikrer at 'beste pris'-perioder ikke bare er relativt billige for dagen, men faktisk billige i absolutte tall. Velg 'Alle' for å vise beste priser uavhengig av deres absolutte prisnivå.",
"enable_min_periods_best": "Når aktivert vil filtrene gradvis bli lempeligere hvis det ikke blir funnet nok perioder. Dette forsøker å nå ønsket minimum antall perioder, noe som kan føre til at mindre optimale tidsrom blir markert som beste-pris-perioder.",
"min_periods_best": "Minimum antall beste-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_step_best": "Prosentandel av den opprinnelige fleksibilitetsterskealen som legges til per avslappingstrinn. For eksempel: med 15% fleksibilitet og 25% trinnstørrelse vil filtrene prøve 15%, 18,75%, 22,5%, osv. Høyere verdier betyr raskere avslapping men mindre presisjon.",
"relaxation_attempts_best": "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 perioder, men tar litt lengre tid.",
"best_price_max_level_gap_count": "Maksimalt antall påfølgende intervaller som kan avvike med nøyaktig ett nivåtrinn fra det nødvendige nivået. For eksempel: med 'Billig' filter og gapantall 1, aksepteres sekvensen 'BILLIG, BILLIG, NORMAL, BILLIG' (NORMAL er ett trinn over BILLIG). Dette forhindrer at perioder blir delt opp av tilfeldige nivåavvik. Standard: 0 (streng filtrering, ingen toleranse)."
},
"submit": "Neste til steg 5"
@ -130,13 +132,15 @@
"peak_price_max_level_gap_count": "Gaptoleranse for nivåfilter",
"enable_min_periods_peak": "Prøv å oppnå minimum antall perioder",
"min_periods_peak": "Minimum antall perioder",
"relaxation_step_peak": "Avslappingstrinn"
"relaxation_step_peak": "Avslappingstrinn",
"relaxation_attempts_peak": "Antall forsøk (fleksnivåer)"
},
"data_description": {
"peak_price_min_level": "Vis kun topprisperioder hvis de inneholder intervaller med prisnivåer ≥ valgt verdi. For eksempel: å velge 'Dyr' betyr at perioden må ha minst ett 'DYR' eller 'VELDIG_DYR' intervall. Dette sikrer at 'topppris'-perioder ikke bare er relativt dyre for dagen, men faktisk dyre i absolutte tall. Velg 'Alle' for å vise topppriser uavhengig av deres absolutte prisnivå.",
"enable_min_periods_peak": "Når aktivert vil filtrene gradvis bli lempeligere hvis det ikke blir funnet nok perioder. Dette forsøker å nå ønsket minimum antall perioder for å sikre at du blir advart om dyre perioder selv på dager med uvanlige prismønstre.",
"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_step_peak": "Prosentandel av den opprinnelige fleksibilitetsterskealen som legges til per avslappingstrinn. For eksempel: med -15% fleksibilitet og 25% trinnstørrelse vil filtrene prøve -15%, -18,75%, -22,5%, osv. Høyere verdier betyr raskere avslapping men mindre presisjon.",
"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.",
"peak_price_max_level_gap_count": "Maksimalt antall påfølgende intervaller som kan avvike med nøyaktig ett nivåtrinn fra det nødvendige nivået. For eksempel: med 'Dyr' filter og gapantall 2, aksepteres sekvensen 'DYR, NORMAL, NORMAL, DYR' (NORMAL er ett trinn under DYR). Dette forhindrer at perioder blir delt opp av tilfeldige nivåavvik. Standard: 0 (streng filtrering, ingen toleranse)."
},
"submit": "Neste til steg 6"

View file

@ -108,13 +108,15 @@
"best_price_max_level_gap_count": "Gaptolerantie voor niveaufilter",
"enable_min_periods_best": "Probeer minimum aantal periodes te bereiken",
"min_periods_best": "Minimum aantal periodes",
"relaxation_step_best": "Ontspanningsstap"
"relaxation_step_best": "Ontspanningsstap",
"relaxation_attempts_best": "Aantal ontspanningspogingen"
},
"data_description": {
"best_price_max_level": "Toon alleen beste prijsperiodes als ze intervallen bevatten met prijsniveaus ≤ geselecteerde waarde. Bijvoorbeeld: selecteren van 'Goedkoop' betekent dat de periode minstens één 'ZEER_GOEDKOOP' of 'GOEDKOOP' interval moet hebben. Dit zorgt ervoor dat 'beste prijs'-periodes niet alleen relatief goedkoop zijn voor de dag, maar daadwerkelijk goedkoop in absolute termen. Selecteer 'Alle' om beste prijzen te tonen ongeacht hun absolute prijsniveau.",
"enable_min_periods_best": "Wanneer ingeschakeld worden filters geleidelijk versoepeld als er niet genoeg periodes worden gevonden. Dit probeert het gewenste minimum aantal periodes te bereiken om ervoor te zorgen dat je kansen hebt om van lage prijzen te profiteren, zelfs op dagen met ongebruikelijke prijspatronen.",
"min_periods_best": "Minimum aantal beste prijsperiodes om naar te streven per dag. Filters worden stap voor stap versoepeld om dit aantal te proberen bereiken. Alleen actief wanneer 'Probeer minimum aantal periodes te bereiken' is ingeschakeld. Standaard: 1",
"relaxation_step_best": "Percentage van de oorspronkelijke flexibiliteitsdrempel om toe te voegen per ontspanningsstap. Bijvoorbeeld: met 15% flexibiliteit en 25% stapgrootte zullen de filters 15%, 18,75%, 22,5%, enz. proberen. Hogere waarden betekenen snellere ontspanning maar minder precisie.",
"relaxation_attempts_best": "Hoeveel keer de ontspanningslogica filters opnieuw mag proberen. Gebruik hogere waarden om meer variaties te testen als dagen extreem grillig zijn. Hogere aantallen vergen meer rekentijd maar vergroten de kans dat het gewenste minimum aantal periodes wordt gehaald.",
"best_price_max_level_gap_count": "Maximum aantal opeenvolgende intervallen dat precies één niveaustap mag afwijken van het vereiste niveau. Bijvoorbeeld: met 'Goedkoop' filter en gaptelling 1 wordt de reeks 'GOEDKOOP, GOEDKOOP, NORMAAL, GOEDKOOP' geaccepteerd (NORMAAL is één stap boven GOEDKOOP). Dit voorkomt dat periodes worden opgesplitst door incidentele niveauafwijkingen. Standaard: 0 (strikte filtering, geen tolerantie)."
},
"submit": "Volgende naar stap 5"
@ -130,13 +132,15 @@
"peak_price_max_level_gap_count": "Gaptolerantie voor niveaufilter",
"enable_min_periods_peak": "Probeer minimum aantal periodes te bereiken",
"min_periods_peak": "Minimum aantal periodes",
"relaxation_step_peak": "Ontspanningsstap"
"relaxation_step_peak": "Ontspanningsstap",
"relaxation_attempts_peak": "Aantal ontspanningspogingen"
},
"data_description": {
"peak_price_min_level": "Toon alleen piekprijsperiodes als ze intervallen bevatten met prijsniveaus ≥ geselecteerde waarde. Bijvoorbeeld: selecteren van 'Duur' betekent dat de periode minstens één 'DUUR' of 'ZEER_DUUR' interval moet hebben. Dit zorgt ervoor dat 'piekprijs'-periodes niet alleen relatief duur zijn voor de dag, maar daadwerkelijk duur in absolute termen. Selecteer 'Alle' om piekprijzen te tonen ongeacht hun absolute prijsniveau.",
"enable_min_periods_peak": "Wanneer ingeschakeld worden filters geleidelijk versoepeld als er niet genoeg periodes worden gevonden. Dit probeert het gewenste minimum aantal periodes te bereiken om ervoor te zorgen dat je wordt gewaarschuwd voor dure periodes, zelfs op dagen met ongebruikelijke prijspatronen.",
"min_periods_peak": "Minimum aantal piekprijsperiodes om naar te streven per dag. Filters worden stap voor stap versoepeld om dit aantal te proberen bereiken. Alleen actief wanneer 'Probeer minimum aantal periodes te bereiken' is ingeschakeld. Standaard: 1",
"relaxation_step_peak": "Percentage van de oorspronkelijke flexibiliteitsdrempel om toe te voegen per ontspanningsstap. Bijvoorbeeld: met -15% flexibiliteit en 25% stapgrootte zullen de filters -15%, -18,75%, -22,5%, enz. proberen. Hogere waarden betekenen snellere ontspanning maar minder precisie.",
"relaxation_attempts_peak": "Hoeveel keer de ontspanningslogica filters opnieuw mag proberen. Gebruik meer pogingen wanneer de piekperiodes moeilijk te vinden zijn door vlakke of zeer grillige dagen. Elke extra poging kost wat extra verwerkingstijd maar vergroot de kans dat periodes worden gevonden.",
"peak_price_max_level_gap_count": "Maximum aantal opeenvolgende intervallen dat precies één niveaustap mag afwijken van het vereiste niveau. Bijvoorbeeld: met 'Duur' filter en gaptelling 2 wordt de reeks 'DUUR, NORMAAL, NORMAAL, DUUR' geaccepteerd (NORMAAL is één stap onder DUUR). Dit voorkomt dat periodes worden opgesplitst door incidentele niveauafwijkingen. Standaard: 0 (strikte filtering, geen tolerantie)."
},
"submit": "Volgende naar stap 6"

View file

@ -108,13 +108,15 @@
"best_price_max_level_gap_count": "Gaptolerens för nivåfilter",
"enable_min_periods_best": "Försök uppnå minsta antal perioder",
"min_periods_best": "Minsta antal perioder",
"relaxation_step_best": "Avslappningssteg"
"relaxation_step_best": "Avslappningssteg",
"relaxation_attempts_best": "Antal avslappningsförsök"
},
"data_description": {
"best_price_max_level": "Visa endast bästa prisperioder om de innehåller intervall med prisnivåer ≤ valt värde. Till exempel: att välja 'Billigt' betyder att perioden måste ha minst ett 'MYCKET_BILLIGT' eller 'BILLIGT' intervall. Detta säkerställer att 'bästa pris'-perioder inte bara är relativt billiga för dagen, utan faktiskt billiga i absoluta tal. Välj 'Alla' för att visa bästa priser oavsett deras absoluta prisnivå.",
"enable_min_periods_best": "När aktiverad kommer filtren att gradvis luckras upp om inte tillräckligt många perioder hittas. Detta försöker uppnå det önskade minsta antalet perioder för att säkerställa att du har möjligheter att dra nytta av låga priser även på dagar med ovanliga prismönster.",
"min_periods_best": "Minsta antal bästa prisperioder att sträva efter per dag. Filtren kommer att luckras upp steg för steg för att försöka uppnå detta antal. Endast aktiv när 'Försök uppnå minsta antal perioder' är aktiverad. Standard: 1",
"relaxation_step_best": "Procentandel av den ursprungliga flexibilitetströskeln att lägga till per avslappningssteg. Till exempel: med 15% flexibilitet och 25% stegstorlek kommer filtren att prova 15%, 18,75%, 22,5%, osv. Högre värden innebär snabbare avslappning men mindre precision.",
"relaxation_attempts_best": "Hur många gånger avslappningslogiken får försöka hitta nya kombinationer av flex och filter. Öka detta om dagarna är extrema och du behöver fler försök för att nå minimikravet. Varje extra försök tar lite mer tid men ökar chansen att hitta perioder.",
"best_price_max_level_gap_count": "Maximalt antal på varandra följande intervaller som får avvika med exakt ett nivåsteg från det erforderliga nivået. Till exempel: med 'Billigt' filter och gapantal 1 accepteras sekvensen 'BILLIGT, BILLIGT, NORMALT, BILLIGT' (NORMALT är ett steg över BILLIGT). Detta förhindrar att perioder delas upp av tillfälliga nivåavvikelser. Standard: 0 (strikt filtrering, ingen tolerans)."
},
"submit": "Nästa till steg 5"
@ -130,13 +132,15 @@
"peak_price_max_level_gap_count": "Gaptolerens för nivåfilter",
"enable_min_periods_peak": "Försök uppnå minsta antal perioder",
"min_periods_peak": "Minsta antal perioder",
"relaxation_step_peak": "Avslappningssteg"
"relaxation_step_peak": "Avslappningssteg",
"relaxation_attempts_peak": "Antal avslappningsförsök"
},
"data_description": {
"peak_price_min_level": "Visa endast topprisperioder om de innehåller intervall med prisnivåer ≥ valt värde. Till exempel måste perioden om du väljer 'Dyr' ha minst ett 'DYR' eller 'MYCKET_DYR' intervall. Detta säkerställer att 'toppris'-perioder inte bara är relativt dyra för dagen, utan faktiskt dyra i absoluta termer (inte bara 'lite dyrare än genomsnittet på en billig dag').",
"enable_min_periods_peak": "När aktiverad kommer filtren att gradvis luckras upp om inte tillräckligt många perioder hittas. Detta försöker uppnå det önskade minsta antalet perioder för att säkerställa att du blir varnad för dyra perioder även på dagar med ovanliga prismönster.",
"min_periods_peak": "Minsta antal topprisperioder att sträva efter per dag. Filtren kommer att luckras upp steg för steg för att försöka uppnå detta antal. Endast aktiv när 'Försök uppnå minsta antal perioder' är aktiverad. Standard: 1",
"relaxation_step_peak": "Procentandel av den ursprungliga flexibilitetströskeln att lägga till per avslappningssteg. Till exempel: med -15% flexibilitet och 25% stegstorlek kommer filtren att prova -15%, -18,75%, -22,5%, osv. Högre värden innebär snabbare avslappning men mindre precision.",
"relaxation_attempts_peak": "Hur många gånger avslappningslogiken får försöka hitta nya kombinationer av flex och filter. Öka detta när topperioderna är svåra att hitta på grund av platta eller mycket volatila dagar. Fler försök ger större chans att hitta perioder men kräver lite mer beräkningstid.",
"peak_price_max_level_gap_count": "Maximalt antal på varandra följande intervaller som får avvika med exakt ett nivåsteg från det erforderliga nivået. Till exempel: med 'Dyrt' filter och gapantal 2 accepteras sekvensen 'DYRT, NORMALT, NORMALT, DYRT' (NORMALT är ett steg under DYRT). Detta förhindrar att perioder delas upp av tillfälliga nivåavvikelser. Standard: 0 (strikt filtrering, ingen tolerans)."
},
"submit": "Nästa till steg 6"

View file

@ -274,22 +274,27 @@ Sometimes, strict filters find too few periods (or none). **Relaxation automatic
enable_min_periods_best: true
min_periods_best: 2 # Try to find at least 2 periods per day
relaxation_step_best: 35 # Increase flex by 35% per step (e.g., 15% → 20.25% → 27.3%)
relaxation_attempts_best: 8 # Flex levels to test (default 8 flex levels = 32 filter combinations)
```
### How It Works (Smart 4×4 Matrix)
Set the matching `relaxation_attempts_peak` value when tuning Peak Price periods. Both sliders accept 1-12 attempts, and the default of 8 flex levels translates to 32 filter-combination tries (8 flex levels × 4 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.
Relaxation uses a **4×4 matrix approach** - trying 4 flexibility levels with 4 different filter combinations (16 attempts total per day):
### How It Works (Adaptive Matrix)
Relaxation uses a **matrix approach** - trying _N_ flexibility levels (your configured **relaxation attempts**) with the same 4 filter combinations. With the default of 8 attempts, that means 8 flex levels × 4 filter combinations = **32 total filter-combination tries per day**; fewer attempts mean fewer flex increases, while more attempts extend the search further before giving up.
#### Phase Matrix
For each day, the system tries:
**4 Flexibility Levels:**
**Flexibility Levels (Attempts):**
1. Original (e.g., 15%)
2. +35% step (e.g., 20.25%)
3. +35% step (e.g., 27.3%)
4. +35% step (e.g., 36.9%)
1. Attempt 1 = Original flex (e.g., 15%)
2. Attempt 2 = +35% step (e.g., 20.25%)
3. Attempt 3 = +35% step (e.g., 27.3%)
4. Attempt 4 = +35% step (e.g., 36.9%)
5. … Attempts 5-8 (default) continue adding +35% each time
6. … Additional attempts keep extending the same pattern up to the 12-attempt maximum
**4 Filter Combinations (per flexibility level):**
@ -309,6 +314,13 @@ Flex 20.25% + Original → SUCCESS! Found 2 periods ✓
(stops here - no need to try more)
```
### Choosing the Number of Attempts
- **Default (8 attempts)** balances speed and completeness for most grids (32 combinations per day for both Best and Peak)
- **Lower (1-4 attempts)** if you only want mild relaxation and keep processing time minimal
- **Higher (9-12 attempts)** for extremely volatile days or when you must hit a strict minimum (up to 48 combinations)
- Remember: each additional attempt adds four more filter combinations because every new flex level still runs all four filter overrides
#### Per-Day Independence
**Critical:** Each day relaxes **independently**:
@ -462,7 +474,7 @@ For advanced configuration patterns and technical deep-dive, see:
**Configuration Parameters:**
| Parameter | Default | Range | Purpose |
| ---------------------------------- | ------- | ------------------ | --------------------------- |
| ---------------------------------- | ------- | ------------------ | ------------------------------ |
| `best_price_flex` | 15% | 0-100% | Search range from daily MIN |
| `best_price_min_period_length` | 60 min | 15-240 | Minimum duration |
| `best_price_min_distance_from_avg` | 2% | 0-20% | Quality threshold |
@ -472,6 +484,7 @@ For advanced configuration patterns and technical deep-dive, see:
| `enable_min_periods_best` | false | true/false | Enable relaxation |
| `min_periods_best` | - | 1-10 | Target periods per day |
| `relaxation_step_best` | - | 5-100% | Relaxation increment |
| `relaxation_attempts_best` | 8 | 1-12 | Flex levels (attempts) per day |
**Peak Price:** Same parameters with `peak_price_*` prefix (defaults: flex=-15%, same otherwise)
@ -512,5 +525,5 @@ The Tibber API provides price levels for each 15-minute interval:
---
**Last updated:** November 12, 2025
**Last updated:** November 15, 2025
**Integration version:** 2.0+