feat(periods): add adaptive filter relaxation for minimum period guarantee

Implemented multi-phase filter relaxation system to ensure minimum number
of best-price and peak-price periods are found, even on days with unusual
price patterns.

New configuration options per period type (best/peak):
- enable_min_periods_{best|peak}: Toggle feature on/off
- min_periods_{best|peak}: Target number of periods (default: 2)
- relaxation_step_{best|peak}: Step size for threshold increase (default: 25%)

Relaxation phases (applied sequentially until target reached):
1. Flex threshold increase (up to 4 steps, e.g., 15% → 18.75% → 22.5% → ...)
2. Volatility filter bypass + continued flex increase
3. All filters off + continued flex increase

Changes to period calculation:
- New calculate_periods_with_relaxation() wrapper function
- filter_periods_by_volatility() now applies post-calculation filtering
- _resolve_period_overlaps() merges baseline + relaxed periods intelligently
- Relaxed periods marked with relaxation_level, relaxation_threshold_* attributes
- Overlap detection prevents double-counting same intervals

Binary sensor attribute ordering improvements:
- Added helper methods for consistent attribute priority
- Relaxation info grouped in priority 6 (after detail attributes)
- Only shown when period was actually relaxed (relaxation_active=true)

Translation updates:
- Added UI labels + descriptions for 6 new config options (all 5 languages)
- Explained relaxation concept with examples in data_description fields
- Clarified volatility filter now applies per-period, not per-day

Impact: Users can configure integration to guarantee minimum number of
periods per day. System automatically relaxes filters when needed while
preserving baseline periods found with strict filters. Particularly useful
for automation reliability on days with flat pricing or unusual patterns.

Fixes edge case where no periods were found despite prices varying enough
for meaningful optimization decisions.
This commit is contained in:
Julian Pawlowski 2025-11-10 03:34:09 +00:00
parent 9640b041e0
commit 40a335dabe
10 changed files with 1051 additions and 123 deletions

View file

@ -18,6 +18,7 @@ from .entity import TibberPricesEntity
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import Callable from collections.abc import Callable
from datetime import datetime
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
@ -283,6 +284,72 @@ class TibberPricesBinarySensor(TibberPricesEntity, BinarySensorEntity):
"periods": [], "periods": [],
} }
def _add_time_attributes(self, attributes: dict, current_period: dict, timestamp: datetime) -> None:
"""Add time-related attributes (priority 1)."""
attributes["timestamp"] = timestamp
if "start" in current_period:
attributes["start"] = current_period["start"]
if "end" in current_period:
attributes["end"] = current_period["end"]
if "duration_minutes" in current_period:
attributes["duration_minutes"] = current_period["duration_minutes"]
def _add_decision_attributes(self, attributes: dict, current_period: dict) -> None:
"""Add core decision attributes (priority 2)."""
if "level" in current_period:
attributes["level"] = current_period["level"]
if "rating_level" in current_period:
attributes["rating_level"] = current_period["rating_level"]
if "rating_difference_%" in current_period:
attributes["rating_difference_%"] = current_period["rating_difference_%"]
def _add_price_attributes(self, attributes: dict, current_period: dict) -> None:
"""Add price statistics attributes (priority 3)."""
if "price_avg" in current_period:
attributes["price_avg"] = current_period["price_avg"]
if "price_min" in current_period:
attributes["price_min"] = current_period["price_min"]
if "price_max" in current_period:
attributes["price_max"] = current_period["price_max"]
if "price_spread" in current_period:
attributes["price_spread"] = current_period["price_spread"]
if "volatility" in current_period:
attributes["volatility"] = current_period["volatility"]
def _add_comparison_attributes(self, attributes: dict, current_period: dict) -> None:
"""Add price comparison attributes (priority 4)."""
if "period_price_diff_from_daily_min" in current_period:
attributes["period_price_diff_from_daily_min"] = current_period["period_price_diff_from_daily_min"]
if "period_price_diff_from_daily_min_%" in current_period:
attributes["period_price_diff_from_daily_min_%"] = current_period["period_price_diff_from_daily_min_%"]
def _add_detail_attributes(self, attributes: dict, current_period: dict) -> None:
"""Add detail information attributes (priority 5)."""
if "period_interval_count" in current_period:
attributes["period_interval_count"] = current_period["period_interval_count"]
if "period_position" in current_period:
attributes["period_position"] = current_period["period_position"]
if "periods_total" in current_period:
attributes["periods_total"] = current_period["periods_total"]
if "periods_remaining" in current_period:
attributes["periods_remaining"] = current_period["periods_remaining"]
def _add_relaxation_attributes(self, attributes: dict, current_period: dict) -> None:
"""
Add relaxation information attributes (priority 6).
Only adds relaxation attributes if the period was actually relaxed.
If relaxation_active is False or missing, no attributes are added.
"""
if current_period.get("relaxation_active"):
attributes["relaxation_active"] = True
if "relaxation_level" in current_period:
attributes["relaxation_level"] = current_period["relaxation_level"]
if "relaxation_threshold_original_%" in current_period:
attributes["relaxation_threshold_original_%"] = current_period["relaxation_threshold_original_%"]
if "relaxation_threshold_applied_%" in current_period:
attributes["relaxation_threshold_applied_%"] = current_period["relaxation_threshold_applied_%"]
def _build_final_attributes_simple( def _build_final_attributes_simple(
self, self,
current_period: dict | None, current_period: dict | None,
@ -296,6 +363,16 @@ class TibberPricesBinarySensor(TibberPricesEntity, BinarySensorEntity):
2. Uses the current/next period from summaries 2. Uses the current/next period from summaries
3. Adds nested period summaries 3. Adds nested period summaries
Attributes are ordered following the documented priority:
1. Time information (timestamp, start, end, duration)
2. Core decision attributes (level, rating_level, rating_difference_%)
3. Price statistics (price_avg, price_min, price_max, price_spread, volatility)
4. Price differences (period_price_diff_from_daily_min, period_price_diff_from_daily_min_%)
5. Detail information (period_interval_count, period_position, periods_total, periods_remaining)
6. Relaxation information (relaxation_active, relaxation_level, relaxation_threshold_original_%,
relaxation_threshold_applied_%) - only if period was relaxed
7. Meta information (periods list)
Args: Args:
current_period: The current or next period (already complete from coordinator) current_period: The current or next period (already complete from coordinator)
period_summaries: All period summaries from coordinator period_summaries: All period summaries from coordinator
@ -306,14 +383,30 @@ class TibberPricesBinarySensor(TibberPricesEntity, BinarySensorEntity):
timestamp = now.replace(minute=current_minute, second=0, microsecond=0) timestamp = now.replace(minute=current_minute, second=0, microsecond=0)
if current_period: if current_period:
# Start with complete period summary from coordinator (already has all attributes!) # Build attributes in priority order using helper methods
attributes = { attributes = {}
"timestamp": timestamp, # ONLY thing we calculate here!
**current_period, # All other attributes come from coordinator
}
# Add nested period summaries last (meta information) # 1. Time information
self._add_time_attributes(attributes, current_period, timestamp)
# 2. Core decision attributes
self._add_decision_attributes(attributes, current_period)
# 3. Price statistics
self._add_price_attributes(attributes, current_period)
# 4. Price differences
self._add_comparison_attributes(attributes, current_period)
# 5. Detail information
self._add_detail_attributes(attributes, current_period)
# 6. Relaxation information (only if period was relaxed)
self._add_relaxation_attributes(attributes, current_period)
# 7. Meta information (periods array)
attributes["periods"] = period_summaries attributes["periods"] = period_summaries
return attributes return attributes
# No current/next period found - return all periods with timestamp # No current/next period found - return all periods with timestamp

View file

@ -45,7 +45,11 @@ from .const import (
CONF_BEST_PRICE_MIN_DISTANCE_FROM_AVG, CONF_BEST_PRICE_MIN_DISTANCE_FROM_AVG,
CONF_BEST_PRICE_MIN_PERIOD_LENGTH, CONF_BEST_PRICE_MIN_PERIOD_LENGTH,
CONF_BEST_PRICE_MIN_VOLATILITY, CONF_BEST_PRICE_MIN_VOLATILITY,
CONF_ENABLE_MIN_PERIODS_BEST,
CONF_ENABLE_MIN_PERIODS_PEAK,
CONF_EXTENDED_DESCRIPTIONS, CONF_EXTENDED_DESCRIPTIONS,
CONF_MIN_PERIODS_BEST,
CONF_MIN_PERIODS_PEAK,
CONF_PEAK_PRICE_FLEX, CONF_PEAK_PRICE_FLEX,
CONF_PEAK_PRICE_MIN_DISTANCE_FROM_AVG, CONF_PEAK_PRICE_MIN_DISTANCE_FROM_AVG,
CONF_PEAK_PRICE_MIN_LEVEL, CONF_PEAK_PRICE_MIN_LEVEL,
@ -55,6 +59,8 @@ from .const import (
CONF_PRICE_RATING_THRESHOLD_LOW, CONF_PRICE_RATING_THRESHOLD_LOW,
CONF_PRICE_TREND_THRESHOLD_FALLING, CONF_PRICE_TREND_THRESHOLD_FALLING,
CONF_PRICE_TREND_THRESHOLD_RISING, CONF_PRICE_TREND_THRESHOLD_RISING,
CONF_RELAXATION_STEP_BEST,
CONF_RELAXATION_STEP_PEAK,
CONF_VOLATILITY_THRESHOLD_HIGH, CONF_VOLATILITY_THRESHOLD_HIGH,
CONF_VOLATILITY_THRESHOLD_MODERATE, CONF_VOLATILITY_THRESHOLD_MODERATE,
CONF_VOLATILITY_THRESHOLD_VERY_HIGH, CONF_VOLATILITY_THRESHOLD_VERY_HIGH,
@ -63,7 +69,11 @@ from .const import (
DEFAULT_BEST_PRICE_MIN_DISTANCE_FROM_AVG, DEFAULT_BEST_PRICE_MIN_DISTANCE_FROM_AVG,
DEFAULT_BEST_PRICE_MIN_PERIOD_LENGTH, DEFAULT_BEST_PRICE_MIN_PERIOD_LENGTH,
DEFAULT_BEST_PRICE_MIN_VOLATILITY, DEFAULT_BEST_PRICE_MIN_VOLATILITY,
DEFAULT_ENABLE_MIN_PERIODS_BEST,
DEFAULT_ENABLE_MIN_PERIODS_PEAK,
DEFAULT_EXTENDED_DESCRIPTIONS, DEFAULT_EXTENDED_DESCRIPTIONS,
DEFAULT_MIN_PERIODS_BEST,
DEFAULT_MIN_PERIODS_PEAK,
DEFAULT_PEAK_PRICE_FLEX, DEFAULT_PEAK_PRICE_FLEX,
DEFAULT_PEAK_PRICE_MIN_DISTANCE_FROM_AVG, DEFAULT_PEAK_PRICE_MIN_DISTANCE_FROM_AVG,
DEFAULT_PEAK_PRICE_MIN_LEVEL, DEFAULT_PEAK_PRICE_MIN_LEVEL,
@ -73,6 +83,8 @@ from .const import (
DEFAULT_PRICE_RATING_THRESHOLD_LOW, DEFAULT_PRICE_RATING_THRESHOLD_LOW,
DEFAULT_PRICE_TREND_THRESHOLD_FALLING, DEFAULT_PRICE_TREND_THRESHOLD_FALLING,
DEFAULT_PRICE_TREND_THRESHOLD_RISING, DEFAULT_PRICE_TREND_THRESHOLD_RISING,
DEFAULT_RELAXATION_STEP_BEST,
DEFAULT_RELAXATION_STEP_PEAK,
DEFAULT_VOLATILITY_THRESHOLD_HIGH, DEFAULT_VOLATILITY_THRESHOLD_HIGH,
DEFAULT_VOLATILITY_THRESHOLD_MODERATE, DEFAULT_VOLATILITY_THRESHOLD_MODERATE,
DEFAULT_VOLATILITY_THRESHOLD_VERY_HIGH, DEFAULT_VOLATILITY_THRESHOLD_VERY_HIGH,
@ -667,6 +679,46 @@ class TibberPricesOptionsFlowHandler(OptionsFlow):
translation_key="price_level", translation_key="price_level",
), ),
), ),
vol.Optional(
CONF_ENABLE_MIN_PERIODS_BEST,
default=self.config_entry.options.get(
CONF_ENABLE_MIN_PERIODS_BEST,
DEFAULT_ENABLE_MIN_PERIODS_BEST,
),
): BooleanSelector(),
vol.Optional(
CONF_MIN_PERIODS_BEST,
default=int(
self.config_entry.options.get(
CONF_MIN_PERIODS_BEST,
DEFAULT_MIN_PERIODS_BEST,
)
),
): NumberSelector(
NumberSelectorConfig(
min=1,
max=10,
step=1,
mode=NumberSelectorMode.SLIDER,
),
),
vol.Optional(
CONF_RELAXATION_STEP_BEST,
default=int(
self.config_entry.options.get(
CONF_RELAXATION_STEP_BEST,
DEFAULT_RELAXATION_STEP_BEST,
)
),
): NumberSelector(
NumberSelectorConfig(
min=5,
max=50,
step=5,
unit_of_measurement="%",
mode=NumberSelectorMode.SLIDER,
),
),
} }
), ),
description_placeholders=self._get_step_description_placeholders("best_price"), description_placeholders=self._get_step_description_placeholders("best_price"),
@ -759,6 +811,46 @@ class TibberPricesOptionsFlowHandler(OptionsFlow):
translation_key="price_level", translation_key="price_level",
), ),
), ),
vol.Optional(
CONF_ENABLE_MIN_PERIODS_PEAK,
default=self.config_entry.options.get(
CONF_ENABLE_MIN_PERIODS_PEAK,
DEFAULT_ENABLE_MIN_PERIODS_PEAK,
),
): BooleanSelector(),
vol.Optional(
CONF_MIN_PERIODS_PEAK,
default=int(
self.config_entry.options.get(
CONF_MIN_PERIODS_PEAK,
DEFAULT_MIN_PERIODS_PEAK,
)
),
): NumberSelector(
NumberSelectorConfig(
min=1,
max=10,
step=1,
mode=NumberSelectorMode.SLIDER,
),
),
vol.Optional(
CONF_RELAXATION_STEP_PEAK,
default=int(
self.config_entry.options.get(
CONF_RELAXATION_STEP_PEAK,
DEFAULT_RELAXATION_STEP_PEAK,
)
),
): NumberSelector(
NumberSelectorConfig(
min=5,
max=50,
step=5,
unit_of_measurement="%",
mode=NumberSelectorMode.SLIDER,
),
),
} }
), ),
description_placeholders=self._get_step_description_placeholders("peak_price"), description_placeholders=self._get_step_description_placeholders("peak_price"),

View file

@ -35,6 +35,12 @@ CONF_BEST_PRICE_MIN_VOLATILITY = "best_price_min_volatility"
CONF_PEAK_PRICE_MIN_VOLATILITY = "peak_price_min_volatility" CONF_PEAK_PRICE_MIN_VOLATILITY = "peak_price_min_volatility"
CONF_BEST_PRICE_MAX_LEVEL = "best_price_max_level" CONF_BEST_PRICE_MAX_LEVEL = "best_price_max_level"
CONF_PEAK_PRICE_MIN_LEVEL = "peak_price_min_level" CONF_PEAK_PRICE_MIN_LEVEL = "peak_price_min_level"
CONF_ENABLE_MIN_PERIODS_BEST = "enable_min_periods_best"
CONF_MIN_PERIODS_BEST = "min_periods_best"
CONF_RELAXATION_STEP_BEST = "relaxation_step_best"
CONF_ENABLE_MIN_PERIODS_PEAK = "enable_min_periods_peak"
CONF_MIN_PERIODS_PEAK = "min_periods_peak"
CONF_RELAXATION_STEP_PEAK = "relaxation_step_peak"
ATTRIBUTION = "Data provided by Tibber" ATTRIBUTION = "Data provided by Tibber"
@ -58,6 +64,12 @@ DEFAULT_BEST_PRICE_MIN_VOLATILITY = "low" # Show best price at any volatility (
DEFAULT_PEAK_PRICE_MIN_VOLATILITY = "low" # Always show peak price (warning relevant even at low spreads) DEFAULT_PEAK_PRICE_MIN_VOLATILITY = "low" # Always show peak price (warning relevant even at low spreads)
DEFAULT_BEST_PRICE_MAX_LEVEL = "any" # Default: show best price periods regardless of price level DEFAULT_BEST_PRICE_MAX_LEVEL = "any" # Default: show best price periods regardless of price level
DEFAULT_PEAK_PRICE_MIN_LEVEL = "any" # Default: show peak price periods regardless of price level DEFAULT_PEAK_PRICE_MIN_LEVEL = "any" # Default: show peak price periods regardless of price level
DEFAULT_ENABLE_MIN_PERIODS_BEST = False # Default: minimum periods feature disabled 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_ENABLE_MIN_PERIODS_PEAK = False # Default: minimum periods feature disabled 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
# Home types # Home types
HOME_TYPE_APARTMENT = "APARTMENT" HOME_TYPE_APARTMENT = "APARTMENT"
@ -204,6 +216,12 @@ PEAK_PRICE_MIN_LEVEL_OPTIONS = [
PRICE_LEVEL_VERY_CHEAP.lower(), # Only show if level ≥ VERY_CHEAP PRICE_LEVEL_VERY_CHEAP.lower(), # Only show if level ≥ VERY_CHEAP
] ]
# Relaxation level constants (for period filter relaxation)
# These describe which filter relaxation was applied to find a period
RELAXATION_NONE = "none" # No relaxation, normal filters
RELAXATION_VOLATILITY_ANY = "volatility_any" # Volatility filter disabled
RELAXATION_ALL_FILTERS_OFF = "all_filters_off" # All filters disabled (last resort)
# Mapping for comparing price levels (used for sorting) # Mapping for comparing price levels (used for sorting)
PRICE_LEVEL_MAPPING = { PRICE_LEVEL_MAPPING = {
PRICE_LEVEL_VERY_CHEAP: -2, PRICE_LEVEL_VERY_CHEAP: -2,

View file

@ -30,6 +30,10 @@ from .const import (
CONF_BEST_PRICE_MIN_DISTANCE_FROM_AVG, CONF_BEST_PRICE_MIN_DISTANCE_FROM_AVG,
CONF_BEST_PRICE_MIN_PERIOD_LENGTH, CONF_BEST_PRICE_MIN_PERIOD_LENGTH,
CONF_BEST_PRICE_MIN_VOLATILITY, CONF_BEST_PRICE_MIN_VOLATILITY,
CONF_ENABLE_MIN_PERIODS_BEST,
CONF_ENABLE_MIN_PERIODS_PEAK,
CONF_MIN_PERIODS_BEST,
CONF_MIN_PERIODS_PEAK,
CONF_PEAK_PRICE_FLEX, CONF_PEAK_PRICE_FLEX,
CONF_PEAK_PRICE_MIN_DISTANCE_FROM_AVG, CONF_PEAK_PRICE_MIN_DISTANCE_FROM_AVG,
CONF_PEAK_PRICE_MIN_LEVEL, CONF_PEAK_PRICE_MIN_LEVEL,
@ -37,6 +41,8 @@ from .const import (
CONF_PEAK_PRICE_MIN_VOLATILITY, CONF_PEAK_PRICE_MIN_VOLATILITY,
CONF_PRICE_RATING_THRESHOLD_HIGH, CONF_PRICE_RATING_THRESHOLD_HIGH,
CONF_PRICE_RATING_THRESHOLD_LOW, CONF_PRICE_RATING_THRESHOLD_LOW,
CONF_RELAXATION_STEP_BEST,
CONF_RELAXATION_STEP_PEAK,
CONF_VOLATILITY_THRESHOLD_HIGH, CONF_VOLATILITY_THRESHOLD_HIGH,
CONF_VOLATILITY_THRESHOLD_MODERATE, CONF_VOLATILITY_THRESHOLD_MODERATE,
CONF_VOLATILITY_THRESHOLD_VERY_HIGH, CONF_VOLATILITY_THRESHOLD_VERY_HIGH,
@ -45,6 +51,10 @@ from .const import (
DEFAULT_BEST_PRICE_MIN_DISTANCE_FROM_AVG, DEFAULT_BEST_PRICE_MIN_DISTANCE_FROM_AVG,
DEFAULT_BEST_PRICE_MIN_PERIOD_LENGTH, DEFAULT_BEST_PRICE_MIN_PERIOD_LENGTH,
DEFAULT_BEST_PRICE_MIN_VOLATILITY, DEFAULT_BEST_PRICE_MIN_VOLATILITY,
DEFAULT_ENABLE_MIN_PERIODS_BEST,
DEFAULT_ENABLE_MIN_PERIODS_PEAK,
DEFAULT_MIN_PERIODS_BEST,
DEFAULT_MIN_PERIODS_PEAK,
DEFAULT_PEAK_PRICE_FLEX, DEFAULT_PEAK_PRICE_FLEX,
DEFAULT_PEAK_PRICE_MIN_DISTANCE_FROM_AVG, DEFAULT_PEAK_PRICE_MIN_DISTANCE_FROM_AVG,
DEFAULT_PEAK_PRICE_MIN_LEVEL, DEFAULT_PEAK_PRICE_MIN_LEVEL,
@ -52,16 +62,19 @@ from .const import (
DEFAULT_PEAK_PRICE_MIN_VOLATILITY, DEFAULT_PEAK_PRICE_MIN_VOLATILITY,
DEFAULT_PRICE_RATING_THRESHOLD_HIGH, DEFAULT_PRICE_RATING_THRESHOLD_HIGH,
DEFAULT_PRICE_RATING_THRESHOLD_LOW, DEFAULT_PRICE_RATING_THRESHOLD_LOW,
DEFAULT_RELAXATION_STEP_BEST,
DEFAULT_RELAXATION_STEP_PEAK,
DEFAULT_VOLATILITY_THRESHOLD_HIGH, DEFAULT_VOLATILITY_THRESHOLD_HIGH,
DEFAULT_VOLATILITY_THRESHOLD_MODERATE, DEFAULT_VOLATILITY_THRESHOLD_MODERATE,
DEFAULT_VOLATILITY_THRESHOLD_VERY_HIGH, DEFAULT_VOLATILITY_THRESHOLD_VERY_HIGH,
DOMAIN, DOMAIN,
PRICE_LEVEL_MAPPING, PRICE_LEVEL_MAPPING,
VOLATILITY_HIGH,
VOLATILITY_MODERATE,
VOLATILITY_VERY_HIGH,
) )
from .period_utils import PeriodConfig, calculate_periods from .period_utils import (
PeriodConfig,
calculate_periods_with_relaxation,
filter_periods_by_volatility,
)
from .price_utils import ( from .price_utils import (
enrich_price_info_with_differences, enrich_price_info_with_differences,
find_price_data_for_interval, find_price_data_for_interval,
@ -735,97 +748,43 @@ class TibberPricesDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
"min_period_length": int(min_period_length), "min_period_length": int(min_period_length),
} }
def _should_show_periods(self, price_info: dict[str, Any], *, reverse_sort: bool) -> bool: def _should_show_periods(
self,
price_info: dict[str, Any],
*,
reverse_sort: bool,
level_override: str | None = None,
) -> bool:
""" """
Check if periods should be shown based on volatility AND level filters (UND-Verknüpfung). Check if periods should be shown based on level filter only.
Note: Volatility filtering is now applied per-period after calculation,
not at the daily level. See _filter_periods_by_volatility().
Args: Args:
price_info: Price information dict with today/yesterday/tomorrow data price_info: Price information dict with today/yesterday/tomorrow data
reverse_sort: If False (best_price), checks max_level filter. reverse_sort: If False (best_price), checks max_level filter.
If True (peak_price), checks min_level filter. If True (peak_price), checks min_level filter.
level_override: Optional override for level filter ("any" to disable)
Returns: Returns:
True if periods should be displayed, False if they should be filtered out. True if periods should be displayed, False if they should be filtered out.
Both conditions must be met for periods to be shown.
""" """
# Check volatility filter # Only check level filter (day-level check: "does today have any qualifying intervals?")
if not self._check_volatility_filter(price_info, reverse_sort=reverse_sort): return self._check_level_filter(
return False price_info,
reverse_sort=reverse_sort,
# Check level filter (UND-Verknüpfung) override=level_override,
return self._check_level_filter(price_info, reverse_sort=reverse_sort)
def _check_volatility_filter(self, price_info: dict[str, Any], *, reverse_sort: bool) -> bool:
"""
Check if today's volatility meets the minimum requirement.
Args:
price_info: Price information dict with today data
reverse_sort: If False (best_price), uses CONF_BEST_PRICE_MIN_VOLATILITY.
If True (peak_price), uses CONF_PEAK_PRICE_MIN_VOLATILITY.
Returns:
True if volatility requirement met, False if periods should be filtered out.
"""
# Get appropriate volatility config based on sensor type
if reverse_sort:
# Peak price sensor
min_volatility = self.config_entry.options.get(
CONF_PEAK_PRICE_MIN_VOLATILITY,
DEFAULT_PEAK_PRICE_MIN_VOLATILITY,
)
else:
# Best price sensor
min_volatility = self.config_entry.options.get(
CONF_BEST_PRICE_MIN_VOLATILITY,
DEFAULT_BEST_PRICE_MIN_VOLATILITY,
) )
# "low" means no filtering (show at any volatility ≥0ct) def _check_level_filter(
if min_volatility == "low": self,
return True price_info: dict[str, Any],
*,
# "any" is legacy alias for "low" (no filtering) reverse_sort: bool,
if min_volatility == "any": override: str | None = None,
return True ) -> bool:
# Get today's price data to calculate volatility
today_prices = price_info.get("today", [])
prices = [p.get("total") for p in today_prices if "total" in p] if today_prices else []
if not prices:
return True # If no prices, don't filter
# Calculate today's spread (volatility metric) in minor units
spread_major = (max(prices) - min(prices)) * 100
# Get volatility thresholds from config
threshold_moderate = self.config_entry.options.get(
CONF_VOLATILITY_THRESHOLD_MODERATE,
DEFAULT_VOLATILITY_THRESHOLD_MODERATE,
)
threshold_high = self.config_entry.options.get(
CONF_VOLATILITY_THRESHOLD_HIGH,
DEFAULT_VOLATILITY_THRESHOLD_HIGH,
)
threshold_very_high = self.config_entry.options.get(
CONF_VOLATILITY_THRESHOLD_VERY_HIGH,
DEFAULT_VOLATILITY_THRESHOLD_VERY_HIGH,
)
# Map min_volatility to threshold and check if spread meets requirement
threshold_map = {
VOLATILITY_MODERATE: threshold_moderate,
VOLATILITY_HIGH: threshold_high,
VOLATILITY_VERY_HIGH: threshold_very_high,
}
required_threshold = threshold_map.get(min_volatility)
return spread_major >= required_threshold if required_threshold is not None else True
def _check_level_filter(self, price_info: dict[str, Any], *, reverse_sort: bool) -> bool:
""" """
Check if today has any intervals that meet the level requirement. Check if today has any intervals that meet the level requirement.
@ -833,13 +792,17 @@ class TibberPricesDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
price_info: Price information dict with today data price_info: Price information dict with today data
reverse_sort: If False (best_price), checks max_level (upper bound filter). reverse_sort: If False (best_price), checks max_level (upper bound filter).
If True (peak_price), checks min_level (lower bound filter). If True (peak_price), checks min_level (lower bound filter).
override: Optional override value (e.g., "any" to disable filter)
Returns: Returns:
True if ANY interval meets the level requirement, False otherwise. True if ANY interval meets the level requirement, False otherwise.
""" """
# Use override if provided
if override is not None:
level_config = override
# Get appropriate config based on sensor type # Get appropriate config based on sensor type
if reverse_sort: elif reverse_sort:
# Peak price: minimum level filter (lower bound) # Peak price: minimum level filter (lower bound)
level_config = self.config_entry.options.get( level_config = self.config_entry.options.get(
CONF_PEAK_PRICE_MIN_LEVEL, CONF_PEAK_PRICE_MIN_LEVEL,
@ -916,6 +879,20 @@ class TibberPricesDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
# Check if best price periods should be shown (apply filters) # Check if best price periods should be shown (apply filters)
show_best_price = self._should_show_periods(price_info, reverse_sort=False) if all_prices else False show_best_price = self._should_show_periods(price_info, reverse_sort=False) if all_prices else False
# Get relaxation configuration for best price
enable_relaxation_best = self.config_entry.options.get(
CONF_ENABLE_MIN_PERIODS_BEST,
DEFAULT_ENABLE_MIN_PERIODS_BEST,
)
min_periods_best = self.config_entry.options.get(
CONF_MIN_PERIODS_BEST,
DEFAULT_MIN_PERIODS_BEST,
)
relaxation_step_best = self.config_entry.options.get(
CONF_RELAXATION_STEP_BEST,
DEFAULT_RELAXATION_STEP_BEST,
)
# Calculate best price periods (or return empty if filtered) # Calculate best price periods (or return empty if filtered)
if show_best_price: if show_best_price:
best_config = self._get_period_config(reverse_sort=False) best_config = self._get_period_config(reverse_sort=False)
@ -930,17 +907,50 @@ class TibberPricesDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
threshold_volatility_high=threshold_volatility_high, threshold_volatility_high=threshold_volatility_high,
threshold_volatility_very_high=threshold_volatility_very_high, threshold_volatility_very_high=threshold_volatility_very_high,
) )
best_periods = calculate_periods(all_prices, config=best_period_config) best_periods, best_relaxation = calculate_periods_with_relaxation(
all_prices,
config=best_period_config,
enable_relaxation=enable_relaxation_best,
min_periods=min_periods_best,
relaxation_step_pct=relaxation_step_best,
should_show_callback=lambda _vol, lvl: self._should_show_periods(
price_info,
reverse_sort=False,
level_override=lvl,
),
)
# Apply period-level volatility filter (after calculation)
min_volatility_best = self.config_entry.options.get(
CONF_BEST_PRICE_MIN_VOLATILITY,
DEFAULT_BEST_PRICE_MIN_VOLATILITY,
)
best_periods = filter_periods_by_volatility(best_periods, min_volatility_best)
else: else:
best_periods = { best_periods = {
"periods": [], "periods": [],
"intervals": [], "intervals": [],
"metadata": {"total_intervals": 0, "total_periods": 0, "config": {}}, "metadata": {"total_intervals": 0, "total_periods": 0, "config": {}},
} }
best_relaxation = {"relaxation_active": False, "relaxation_attempted": False}
# Check if peak price periods should be shown (apply filters) # Check if peak price periods should be shown (apply filters)
show_peak_price = self._should_show_periods(price_info, reverse_sort=True) if all_prices else False show_peak_price = self._should_show_periods(price_info, reverse_sort=True) if all_prices else False
# Get relaxation configuration for peak price
enable_relaxation_peak = self.config_entry.options.get(
CONF_ENABLE_MIN_PERIODS_PEAK,
DEFAULT_ENABLE_MIN_PERIODS_PEAK,
)
min_periods_peak = self.config_entry.options.get(
CONF_MIN_PERIODS_PEAK,
DEFAULT_MIN_PERIODS_PEAK,
)
relaxation_step_peak = self.config_entry.options.get(
CONF_RELAXATION_STEP_PEAK,
DEFAULT_RELAXATION_STEP_PEAK,
)
# Calculate peak price periods (or return empty if filtered) # Calculate peak price periods (or return empty if filtered)
if show_peak_price: if show_peak_price:
peak_config = self._get_period_config(reverse_sort=True) peak_config = self._get_period_config(reverse_sort=True)
@ -955,17 +965,38 @@ class TibberPricesDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
threshold_volatility_high=threshold_volatility_high, threshold_volatility_high=threshold_volatility_high,
threshold_volatility_very_high=threshold_volatility_very_high, threshold_volatility_very_high=threshold_volatility_very_high,
) )
peak_periods = calculate_periods(all_prices, config=peak_period_config) peak_periods, peak_relaxation = calculate_periods_with_relaxation(
all_prices,
config=peak_period_config,
enable_relaxation=enable_relaxation_peak,
min_periods=min_periods_peak,
relaxation_step_pct=relaxation_step_peak,
should_show_callback=lambda _vol, lvl: self._should_show_periods(
price_info,
reverse_sort=True,
level_override=lvl,
),
)
# Apply period-level volatility filter (after calculation)
min_volatility_peak = self.config_entry.options.get(
CONF_PEAK_PRICE_MIN_VOLATILITY,
DEFAULT_PEAK_PRICE_MIN_VOLATILITY,
)
peak_periods = filter_periods_by_volatility(peak_periods, min_volatility_peak)
else: else:
peak_periods = { peak_periods = {
"periods": [], "periods": [],
"intervals": [], "intervals": [],
"metadata": {"total_intervals": 0, "total_periods": 0, "config": {}}, "metadata": {"total_intervals": 0, "total_periods": 0, "config": {}},
} }
peak_relaxation = {"relaxation_active": False, "relaxation_attempted": False}
return { return {
"best_price": best_periods, "best_price": best_periods,
"best_price_relaxation": best_relaxation,
"peak_price": peak_periods, "peak_price": peak_periods,
"peak_price_relaxation": peak_relaxation,
} }
def _transform_data_for_main_entry(self, raw_data: dict[str, Any]) -> dict[str, Any]: def _transform_data_for_main_entry(self, raw_data: dict[str, Any]) -> dict[str, Any]:

View file

@ -4,7 +4,10 @@ from __future__ import annotations
import logging import logging
from datetime import date, datetime, timedelta from datetime import date, datetime, timedelta
from typing import Any, NamedTuple from typing import TYPE_CHECKING, Any, NamedTuple
if TYPE_CHECKING:
from collections.abc import Callable
from homeassistant.util import dt as dt_util from homeassistant.util import dt as dt_util
@ -538,7 +541,6 @@ def _build_period_summary_dict(
# 5. Detail information (additional context) # 5. Detail information (additional context)
"period_interval_count": period_data.period_length, "period_interval_count": period_data.period_length,
"period_position": period_data.period_idx, "period_position": period_data.period_idx,
# 6. Meta information (technical details)
"periods_total": period_data.total_periods, "periods_total": period_data.total_periods,
"periods_remaining": period_data.total_periods - period_data.period_idx, "periods_remaining": period_data.total_periods - period_data.period_idx,
} }
@ -681,3 +683,635 @@ def _extract_period_summaries(
summaries.append(summary) summaries.append(summary)
return summaries return summaries
def _recalculate_period_metadata(periods: list[dict]) -> None:
"""
Recalculate period metadata after merging periods.
Updates period_position, periods_total, and periods_remaining for all periods
based on chronological order.
This must be called after _resolve_period_overlaps() to ensure metadata
reflects the final merged period list.
Args:
periods: List of period summary dicts (mutated in-place)
"""
if not periods:
return
# Sort periods chronologically by start time
periods.sort(key=lambda p: p.get("start") or dt_util.now())
# Update metadata for all periods
total_periods = len(periods)
for position, period in enumerate(periods, 1):
period["period_position"] = position
period["periods_total"] = total_periods
period["periods_remaining"] = total_periods - position
def filter_periods_by_volatility(
periods_data: dict[str, Any],
min_volatility: str,
) -> dict[str, Any]:
"""
Filter calculated periods based on their internal volatility.
This applies period-level volatility filtering AFTER periods have been calculated.
Removes periods that don't meet the minimum volatility requirement based on their
own price spread (volatility attribute), not the daily volatility.
Args:
periods_data: Dict with "periods" and "intervals" lists from calculate_periods_with_relaxation()
min_volatility: Minimum volatility level required ("low", "moderate", "high", "very_high")
Returns:
Filtered periods_data dict with updated periods, intervals, and metadata.
"""
periods = periods_data.get("periods", [])
if not periods:
return periods_data
# "low" means no filtering (accept any volatility level)
if min_volatility == "low":
return periods_data
# Define volatility hierarchy (LOW < MODERATE < HIGH < VERY_HIGH)
volatility_levels = ["LOW", "MODERATE", "HIGH", "VERY_HIGH"]
# Map filter config values to actual level names
config_to_level = {
"low": "LOW",
"moderate": "MODERATE",
"high": "HIGH",
"very_high": "VERY_HIGH",
}
min_level = config_to_level.get(min_volatility, "LOW")
# Filter periods based on their volatility
filtered_periods = []
for period in periods:
period_volatility = period.get("volatility", "MODERATE")
# Check if period's volatility meets or exceeds minimum requirement
try:
period_idx = volatility_levels.index(period_volatility)
min_idx = volatility_levels.index(min_level)
except ValueError:
# If level not found, don't filter out this period
filtered_periods.append(period)
else:
if period_idx >= min_idx:
filtered_periods.append(period)
# If no periods left after filtering, return empty structure
if not filtered_periods:
return {
"periods": [],
"intervals": [],
"metadata": {
"total_intervals": 0,
"total_periods": 0,
"config": periods_data.get("metadata", {}).get("config", {}),
},
}
# Collect intervals from filtered periods
filtered_intervals = []
for period in filtered_periods:
filtered_intervals.extend(period.get("intervals", []))
# Update metadata
return {
"periods": filtered_periods,
"intervals": filtered_intervals,
"metadata": {
"total_intervals": len(filtered_intervals),
"total_periods": len(filtered_periods),
"config": periods_data.get("metadata", {}).get("config", {}),
},
}
def calculate_periods_with_relaxation( # noqa: PLR0913, PLR0912, PLR0915, C901 - Complex multi-phase relaxation
all_prices: list[dict],
*,
config: PeriodConfig,
enable_relaxation: bool,
min_periods: int,
relaxation_step_pct: int,
should_show_callback: Callable[[str | None, str | None], bool],
) -> tuple[dict[str, Any], dict[str, Any]]:
"""
Calculate periods with optional filter relaxation.
If min_periods is not reached with normal filters, this function gradually
relaxes filters in multiple phases:
Phase 1: Increase flex threshold step-by-step (up to 4 attempts)
Phase 2: Disable volatility filter (set to "any")
Phase 3: Disable level filter (set to "any")
Args:
all_prices: All price data points
config: Base period configuration
enable_relaxation: Whether relaxation is enabled
min_periods: Minimum number of periods required (only used if enable_relaxation=True)
relaxation_step_pct: Percentage of original flex to add per relaxation step
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:
Tuple of (periods_result, relaxation_metadata):
- periods_result: Same format as calculate_periods() output
- relaxation_metadata: Dict with relaxation information
"""
# If relaxation is disabled, just run normal calculation
if not enable_relaxation:
periods_result = calculate_periods(all_prices, config=config)
return periods_result, {
"relaxation_active": False,
"relaxation_attempted": False,
"min_periods_requested": 0,
"periods_found": len(periods_result["periods"]),
}
# Phase 0: Try with normal filters first
# Check if periods should be shown with current filters
if not should_show_callback(None, None):
# Filters prevent showing any periods - skip normal calculation
baseline_periods = []
periods_found = 0
else:
baseline_result = calculate_periods(all_prices, config=config)
baseline_periods = baseline_result["periods"]
periods_found = len(baseline_periods)
if periods_found >= min_periods:
# Success with normal filters - reconstruct full result
periods_result = calculate_periods(all_prices, config=config)
return periods_result, {
"relaxation_active": False,
"relaxation_attempted": False,
"min_periods_requested": min_periods,
"periods_found": periods_found,
}
# Not enough periods - start relaxation
# Keep accumulated_periods for incremental merging across phases
accumulated_periods = baseline_periods.copy()
_LOGGER.info(
"Found %d baseline periods (need %d), starting filter relaxation",
periods_found,
min_periods,
)
original_flex = abs(config.flex) # Use absolute value for calculations
relaxation_increment = original_flex * (relaxation_step_pct / 100.0)
phases_used = []
# Phase 1: Relax flex threshold (up to 4 attempts)
for step in range(1, 5):
new_flex = original_flex + (step * relaxation_increment)
new_flex = min(new_flex, 100.0) # Cap at 100%
# Restore sign for best/peak price
if config.reverse_sort:
new_flex = -new_flex # Peak price uses negative values
relaxed_config = config._replace(flex=new_flex)
relaxed_result = calculate_periods(all_prices, config=relaxed_config)
new_relaxed_periods = relaxed_result["periods"]
# Convert to percentage for display (0.25 → 25.0)
relaxation_level = f"price_diff_{round(abs(new_flex) * 100, 1)}%"
phases_used.append(relaxation_level)
# Merge with accumulated periods (baseline + previous relaxation phases), resolve overlaps
merged_periods, standalone_count = _resolve_period_overlaps(
accumulated_periods, new_relaxed_periods, config.min_period_length
)
total_count = len(baseline_periods) + standalone_count
_LOGGER.debug(
"Relaxation attempt %d: flex=%.2f%%, found %d new periods (%d standalone, %d extensions), total %d periods",
step,
abs(new_flex) * 100,
len(new_relaxed_periods),
standalone_count,
len(new_relaxed_periods) - standalone_count,
total_count,
)
if total_count >= min_periods:
# Mark relaxed periods (those not from baseline)
for period in merged_periods:
if period.get("relaxation_active"):
_mark_periods_with_relaxation(
[period],
relaxation_level,
original_flex,
abs(new_flex),
)
# Recalculate metadata after merge (position, total, remaining)
_recalculate_period_metadata(merged_periods)
# Update accumulated periods for potential next phase
accumulated_periods = merged_periods.copy()
# Reconstruct result with merged periods
periods_result = relaxed_result.copy()
periods_result["periods"] = merged_periods
return periods_result, {
"relaxation_active": True,
"relaxation_attempted": True,
"min_periods_requested": min_periods,
"periods_found": total_count,
"phases_used": phases_used,
"final_level": relaxation_level,
}
# Phase 2: Relax volatility filter + reset and increase threshold
_LOGGER.info(
"Phase 1 insufficient (%d/%d periods), trying Phase 2: relax volatility filter", total_count, min_periods
)
if should_show_callback("any", None): # Volatility filter can be disabled
# Phase 2: Try with reset threshold and volatility=any (up to 4 steps)
for step in range(1, 5):
new_flex = original_flex + (step * relaxation_increment)
new_flex = min(new_flex, 100.0) # Cap at 100%
# Restore sign for best/peak price
if config.reverse_sort:
new_flex = -new_flex
relaxed_config = config._replace(flex=new_flex)
relaxed_result = calculate_periods(all_prices, config=relaxed_config)
new_relaxed_periods = relaxed_result["periods"]
relaxation_level = f"volatility_any+price_diff_{round(abs(new_flex) * 100, 1)}%"
phases_used.append(relaxation_level)
# Merge with accumulated periods (baseline + previous relaxation phases), resolve overlaps
merged_periods, standalone_count = _resolve_period_overlaps(
accumulated_periods, new_relaxed_periods, config.min_period_length
)
total_count = len(baseline_periods) + standalone_count
_LOGGER.debug(
"Phase 2 attempt %d (volatility=any, flex=%.2f%%): found %d new periods "
"(%d standalone, %d extensions), total %d periods",
step,
abs(new_flex) * 100,
len(new_relaxed_periods),
standalone_count,
len(new_relaxed_periods) - standalone_count,
total_count,
)
if total_count >= min_periods:
# Mark relaxed periods (those not from baseline)
for period in merged_periods:
if period.get("relaxation_active"):
_mark_periods_with_relaxation(
[period],
relaxation_level,
original_flex,
abs(new_flex),
)
# Recalculate metadata after merge (position, total, remaining)
_recalculate_period_metadata(merged_periods)
# Update accumulated periods for potential next phase
accumulated_periods = merged_periods.copy()
# Reconstruct result with merged periods
periods_result = relaxed_result.copy()
periods_result["periods"] = merged_periods
return periods_result, {
"relaxation_active": True,
"relaxation_attempted": True,
"min_periods_requested": min_periods,
"periods_found": total_count,
"phases_used": phases_used,
"final_level": relaxation_level,
}
else:
_LOGGER.debug("Phase 2 skipped: volatility filter prevents showing periods")
# Phase 3: Relax level filter + reset and increase threshold
_LOGGER.info("Phase 2 insufficient (%d/%d periods), trying Phase 3: relax level filter", total_count, min_periods)
if should_show_callback("any", "any"): # Both filters can be disabled
# Phase 3: Try with reset threshold and both filters=any (up to 4 steps)
for step in range(1, 5):
new_flex = original_flex + (step * relaxation_increment)
new_flex = min(new_flex, 100.0) # Cap at 100%
# Restore sign for best/peak price
if config.reverse_sort:
new_flex = -new_flex
relaxed_config = config._replace(flex=new_flex)
relaxed_result = calculate_periods(all_prices, config=relaxed_config)
new_relaxed_periods = relaxed_result["periods"]
relaxation_level = f"all_filters_off+price_diff_{round(abs(new_flex) * 100, 1)}%"
phases_used.append(relaxation_level)
# Merge with accumulated periods (baseline + previous relaxation phases), resolve overlaps
merged_periods, standalone_count = _resolve_period_overlaps(
accumulated_periods, new_relaxed_periods, config.min_period_length
)
total_count = len(baseline_periods) + standalone_count
_LOGGER.debug(
"Phase 3 attempt %d (all_filters=any, flex=%.2f%%): found %d new periods "
"(%d standalone, %d extensions), total %d periods",
step,
abs(new_flex) * 100,
len(new_relaxed_periods),
standalone_count,
len(new_relaxed_periods) - standalone_count,
total_count,
)
if total_count >= min_periods:
# Mark relaxed periods (those not from baseline)
for period in merged_periods:
if period.get("relaxation_active"):
_mark_periods_with_relaxation(
[period],
relaxation_level,
original_flex,
abs(new_flex),
)
# Recalculate metadata after merge (position, total, remaining)
_recalculate_period_metadata(merged_periods)
# Update accumulated periods (final result)
accumulated_periods = merged_periods.copy()
# Reconstruct result with merged periods
if total_count >= min_periods:
# Mark relaxed periods (those not from baseline)
for period in merged_periods:
if period.get("relaxation_active"):
_mark_periods_with_relaxation(
[period],
relaxation_level,
original_flex,
abs(new_flex),
)
# Reconstruct result with merged periods
periods_result = relaxed_result.copy()
periods_result["periods"] = merged_periods
return periods_result, {
"relaxation_active": True,
"relaxation_attempted": True,
"min_periods_requested": min_periods,
"periods_found": total_count,
"phases_used": phases_used,
"final_level": relaxation_level,
}
else:
_LOGGER.debug("Phase 3 skipped: level filter prevents showing periods")
# All relaxation phases exhausted - return what we have
# Use accumulated periods (may include baseline + partial relaxation results)
_LOGGER.warning(
"All relaxation phases exhausted - found only %d of %d requested periods. Returning available periods.",
total_count,
min_periods,
)
# Use accumulated periods (includes baseline + any successful relaxation merges)
final_periods = accumulated_periods.copy()
final_count = len(baseline_periods) + sum(
1 for p in final_periods if p.get("relaxation_active") and not p.get("is_extension")
)
# Mark relaxed periods with final relaxation level (best we could do)
if final_periods:
final_relaxation_level = phases_used[-1] if phases_used else "none"
for period in final_periods:
if period.get("relaxation_active"):
_mark_periods_with_relaxation(
[period],
final_relaxation_level,
original_flex,
original_flex, # Return original since we couldn't meet minimum
)
# Recalculate metadata one final time
_recalculate_period_metadata(final_periods)
# Reconstruct result structure
# Use last relaxed_result if available, otherwise baseline_result
if "relaxed_result" in locals():
periods_result = relaxed_result.copy()
else:
# No relaxation happened - construct minimal result
periods_result = {"periods": [], "metadata": {}, "reference_data": {}}
periods_result["periods"] = final_periods
return periods_result, {
"relaxation_active": True,
"relaxation_attempted": True,
"relaxation_incomplete": True,
"min_periods_requested": min_periods,
"periods_found": final_count,
"phases_used": phases_used,
"final_level": phases_used[-1] if phases_used else "none",
}
def _mark_periods_with_relaxation(
periods: list[dict],
relaxation_level: str,
original_threshold: float,
applied_threshold: float,
) -> None:
"""
Mark periods with relaxation information (mutates period dicts in-place).
Uses consistent 'relaxation_*' prefix for all relaxation-related attributes.
Args:
periods: List of period dicts to mark
relaxation_level: String describing the relaxation level
original_threshold: Original flex threshold value (decimal, e.g., 0.19 for 19%)
applied_threshold: Actually applied threshold value (decimal, e.g., 0.25 for 25%)
"""
for period in periods:
period["relaxation_active"] = True
period["relaxation_level"] = relaxation_level
# Convert decimal to percentage for display (0.19 → 19.0)
period["relaxation_threshold_original_%"] = round(original_threshold * 100, 1)
period["relaxation_threshold_applied_%"] = round(applied_threshold * 100, 1)
def _resolve_period_overlaps( # noqa: PLR0912 - Complex overlap resolution with segment validation
existing_periods: list[dict],
new_relaxed_periods: list[dict],
min_period_length: int,
) -> tuple[list[dict], int]:
"""
Resolve overlaps between existing periods and newly found relaxed periods.
Existing periods (baseline + previous relaxation phases) have priority and remain unchanged.
Newly relaxed periods are adjusted to not overlap with existing periods.
After splitting relaxed periods to avoid overlaps, each segment is validated against
min_period_length. Segments shorter than this threshold are discarded.
This function is called incrementally after each relaxation phase:
- Phase 1: existing = baseline
- Phase 2: existing = baseline + Phase 1 results
- Phase 3: existing = baseline + Phase 1 + Phase 2 results
Args:
existing_periods: All previously found periods (baseline + earlier relaxation phases)
new_relaxed_periods: Periods found in current relaxation phase (will be adjusted)
min_period_length: Minimum period length in minutes (segments shorter than this are discarded)
Returns:
Tuple of (merged_periods, count_standalone_relaxed):
- merged_periods: All periods (existing + adjusted new), sorted by start time
- count_standalone_relaxed: Number of new relaxed periods that count toward min_periods
(excludes extensions of existing periods)
"""
if not new_relaxed_periods:
return existing_periods.copy(), 0
if not existing_periods:
# No overlaps possible - all relaxed periods are standalone
return new_relaxed_periods.copy(), len(new_relaxed_periods)
merged = existing_periods.copy()
count_standalone = 0
for relaxed in new_relaxed_periods:
relaxed_start = relaxed["start"]
relaxed_end = relaxed["end"]
# Find all overlapping existing periods
overlaps = []
for existing in existing_periods:
existing_start = existing["start"]
existing_end = existing["end"]
# Check for overlap
if relaxed_start < existing_end and relaxed_end > existing_start:
overlaps.append((existing_start, existing_end))
if not overlaps:
# No overlap - add as standalone period
merged.append(relaxed)
count_standalone += 1
else:
# Has overlaps - split the relaxed period into non-overlapping segments
segments = _split_period_by_overlaps(relaxed_start, relaxed_end, overlaps)
for seg_start, seg_end in segments:
# Calculate segment duration in minutes
segment_duration_minutes = int((seg_end - seg_start).total_seconds() / 60)
# Skip segment if it's too short
if segment_duration_minutes < min_period_length:
continue
# Check if segment is directly adjacent to existing period (= extension)
is_extension = False
for existing in existing_periods:
if seg_end == existing["start"] or seg_start == existing["end"]:
is_extension = True
break
# Create adjusted period segment
adjusted_period = relaxed.copy()
adjusted_period["start"] = seg_start
adjusted_period["end"] = seg_end
adjusted_period["duration_minutes"] = segment_duration_minutes
# Mark as adjusted and potentially as extension
adjusted_period["adjusted_for_overlap"] = True
adjusted_period["original_start"] = relaxed_start
adjusted_period["original_end"] = relaxed_end
if is_extension:
adjusted_period["is_extension"] = True
else:
# Standalone segment counts toward min_periods
count_standalone += 1
merged.append(adjusted_period)
# Sort all periods by start time
merged.sort(key=lambda p: p["start"])
return merged, count_standalone
def _split_period_by_overlaps(
period_start: datetime,
period_end: datetime,
overlaps: list[tuple[datetime, datetime]],
) -> list[tuple[datetime, datetime]]:
"""
Split a time period into segments that don't overlap with given ranges.
Args:
period_start: Start of period to split
period_end: End of period to split
overlaps: List of (start, end) tuples representing overlapping ranges
Returns:
List of (start, end) tuples for non-overlapping segments
Example:
period: 09:00-15:00
overlaps: [(10:00-12:00), (14:00-16:00)]
result: [(09:00-10:00), (12:00-14:00)]
"""
# Sort overlaps by start time
sorted_overlaps = sorted(overlaps, key=lambda x: x[0])
segments = []
current_pos = period_start
for overlap_start, overlap_end in sorted_overlaps:
# Add segment before this overlap (if any)
if current_pos < overlap_start:
segments.append((current_pos, overlap_start))
# Move position past this overlap
current_pos = max(current_pos, overlap_end)
# Add final segment after all overlaps (if any)
if current_pos < period_end:
segments.append((current_pos, period_end))
return segments

View file

@ -105,11 +105,17 @@
"best_price_flex": "Flexibilität: Maximal über dem Mindestpreis", "best_price_flex": "Flexibilität: Maximal über dem Mindestpreis",
"best_price_min_distance_from_avg": "Mindestabstand: Erforderlich unter dem Tagesdurchschnitt", "best_price_min_distance_from_avg": "Mindestabstand: Erforderlich unter dem Tagesdurchschnitt",
"best_price_min_volatility": "Mindest-Volatilitätsfilter", "best_price_min_volatility": "Mindest-Volatilitätsfilter",
"best_price_max_level": "Preisniveau-Filter (Optional)" "best_price_max_level": "Preisniveau-Filter (Optional)",
"enable_min_periods_best": "Mindestanzahl Perioden anstreben",
"min_periods_best": "Mindestanzahl Perioden",
"relaxation_step_best": "Lockerungsschritt"
}, },
"data_description": { "data_description": {
"best_price_min_volatility": "Zeigt Bestpreis-Perioden nur an, wenn die heutige Volatilität mindestens diesem Level entspricht. Standard: 'Niedrig' (zeigt unabhängig von Volatilität) - Batterie-Optimierung ist auch bei geringen Preisschwankungen nützlich. Wähle 'Moderat'/'Hoch' um Perioden nur an volatileren Tagen anzuzeigen. UND-Verknüpfung: Volatilität UND Niveaufilter müssen beide erfüllt sein.", "best_price_min_volatility": "Zeigt Bestpreis-Perioden nur an, wenn die interne Preisvolatilität der Periode (Preisspanne innerhalb der Periode) mindestens diesem Level entspricht. Standard: 'Niedrig' (zeigt Perioden mit beliebigem Volatilitätslevel) - ermöglicht das Finden günstiger Perioden auch wenn die Preise stabil sind. Wähle 'Moderat'/'Hoch' um nur Perioden mit signifikanten Preisschwankungen anzuzeigen, was auf dynamischere Preismöglichkeiten hinweisen kann.",
"best_price_max_level": "Zeigt Bestpreis-Perioden nur an, wenn mindestens ein Intervall heute ein Preisniveau ≤ dem gewählten Wert hat. UND-Verknüpfung: Volatilitätsfilter (falls gesetzt) UND Niveaufilter müssen beide erfüllt sein. Nützlich um Batterieladen an teuren Tagen zu vermeiden. Wähle 'Beliebig' um diesen Filter zu deaktivieren." "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.",
"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."
}, },
"submit": "Weiter zu Schritt 5" "submit": "Weiter zu Schritt 5"
}, },
@ -121,11 +127,17 @@
"peak_price_flex": "Flexibilität: Maximal unter dem Höchstpreis (negativer Wert)", "peak_price_flex": "Flexibilität: Maximal unter dem Höchstpreis (negativer Wert)",
"peak_price_min_distance_from_avg": "Mindestabstand: Erforderlich über dem Tagesdurchschnitt", "peak_price_min_distance_from_avg": "Mindestabstand: Erforderlich über dem Tagesdurchschnitt",
"peak_price_min_volatility": "Mindest-Volatilitätsfilter", "peak_price_min_volatility": "Mindest-Volatilitätsfilter",
"peak_price_min_level": "Preisniveau-Filter (Optional)" "peak_price_min_level": "Preisniveau-Filter (Optional)",
"enable_min_periods_peak": "Mindestanzahl Perioden anstreben",
"min_periods_peak": "Mindestanzahl Perioden",
"relaxation_step_peak": "Lockerungsschritt"
}, },
"data_description": { "data_description": {
"peak_price_min_volatility": "Zeigt Spitzenpreis-Perioden nur an, wenn die heutige Volatilität mindestens diesem Level entspricht. Standard: 'Niedrig' (zeigt unabhängig von Volatilität) - Spitzenwarnungen sind auch bei niedrigen Spannen relevant, da teure Stunden vermeiden immer wichtig ist. Wähle 'Moderat'/'Hoch' um Peaks nur an volatilen Tagen anzuzeigen. UND-Verknüpfung: Volatilität UND Niveaufilter müssen beide erfüllt sein.", "peak_price_min_volatility": "Zeigt Spitzenpreis-Perioden nur an, wenn die interne Preisvolatilität der Periode (Preisspanne innerhalb der Periode) mindestens diesem Level entspricht. Standard: 'Niedrig' (zeigt Perioden mit beliebigem Volatilitätslevel) - ermöglicht das Identifizieren teurer Perioden auch wenn die Preise stabil sind. Wähle 'Moderat'/'Hoch' um nur Perioden mit signifikanten Preisschwankungen anzuzeigen, was auf dringenderen Bedarf hinweisen kann, diese Zeiten zu vermeiden.",
"peak_price_min_level": "Zeigt Spitzenpreis-Perioden nur an, wenn mindestens ein Intervall heute ein Preisniveau ≥ dem gewählten Wert hat. UND-Verknüpfung: Volatilitätsfilter (falls gesetzt) UND Niveaufilter müssen beide erfüllt sein. Normalerweise auf 'Beliebig' gesetzt, da Spitzenperioden relativ zum Tag sind. Wähle 'Beliebig' um diesen Filter zu deaktivieren." "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.",
"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."
}, },
"submit": "Weiter zu Schritt 6" "submit": "Weiter zu Schritt 6"
}, },

View file

@ -105,11 +105,17 @@
"best_price_flex": "Flexibility: Maximum above minimum price", "best_price_flex": "Flexibility: Maximum above minimum price",
"best_price_min_distance_from_avg": "Minimum Distance: Required below daily average", "best_price_min_distance_from_avg": "Minimum Distance: Required below daily average",
"best_price_min_volatility": "Minimum Volatility Filter", "best_price_min_volatility": "Minimum Volatility Filter",
"best_price_max_level": "Price Level Filter (Optional)" "best_price_max_level": "Price Level Filter (Optional)",
"enable_min_periods_best": "Try to Achieve Minimum Period Count",
"min_periods_best": "Minimum Periods Required",
"relaxation_step_best": "Filter Relaxation Step Size"
}, },
"data_description": { "data_description": {
"best_price_min_volatility": "Only show best price periods when today's volatility meets or exceeds this level. Default: 'Low' (show regardless of volatility) - battery optimization is useful even with small price variations. Select 'Moderate'/'High' to only show periods on more volatile days. Works with AND logic: volatility AND level filter must both pass.", "best_price_min_volatility": "Only show best price periods when the period's internal price volatility (spread within the period) meets or exceeds this level. Default: 'Low' (show periods with any volatility level) - allows finding cheap periods even if prices are stable. Select 'Moderate'/'High' to only show periods with significant price variations, which may indicate more dynamic pricing opportunities.",
"best_price_max_level": "Only show best price periods if at least one interval today has a price level ≤ selected value. Works with AND logic: volatility filter (if set) AND level filter must both pass. Useful to avoid battery charging on expensive days. Select 'Any' to disable this filter." "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.",
"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."
}, },
"submit": "Next to Step 5" "submit": "Next to Step 5"
}, },
@ -121,11 +127,17 @@
"peak_price_flex": "Flexibility: Maximum below maximum price (negative value)", "peak_price_flex": "Flexibility: Maximum below maximum price (negative value)",
"peak_price_min_distance_from_avg": "Minimum Distance: Required above daily average", "peak_price_min_distance_from_avg": "Minimum Distance: Required above daily average",
"peak_price_min_volatility": "Minimum Volatility Filter", "peak_price_min_volatility": "Minimum Volatility Filter",
"peak_price_min_level": "Price Level Filter (Optional)" "peak_price_min_level": "Price Level Filter (Optional)",
"enable_min_periods_peak": "Try to Achieve Minimum Period Count",
"min_periods_peak": "Minimum Periods Required",
"relaxation_step_peak": "Filter Relaxation Step Size"
}, },
"data_description": { "data_description": {
"peak_price_min_volatility": "Only show peak price periods when today's volatility meets or exceeds this level. Default: 'Low' (show regardless of volatility) - peak warnings are relevant even at low spreads since avoiding expensive hours always matters. Select 'Moderate'/'High' to only show peaks on volatile days. Works with AND logic: volatility AND level filter must both pass.", "peak_price_min_volatility": "Only show peak price periods when the period's internal price volatility (spread within the period) meets or exceeds this level. Default: 'Low' (show periods with any volatility level) - allows identifying expensive periods even if prices are stable. Select 'Moderate'/'High' to only show periods with significant price variations, which may indicate more urgent need to avoid these times.",
"peak_price_min_level": "Only show peak price periods if at least one interval today has a price level ≥ selected value. Works with AND logic: volatility filter (if set) AND level filter must both pass. Typically set to 'Any' since peak periods are relative to the day. Select 'Any' to disable this filter." "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.",
"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."
}, },
"submit": "Next to Step 6" "submit": "Next to Step 6"
}, },

View file

@ -105,11 +105,17 @@
"best_price_flex": "Fleksibilitet: Maksimum % over minimumspris", "best_price_flex": "Fleksibilitet: Maksimum % over minimumspris",
"best_price_min_distance_from_avg": "Minimumsavstand: Påkrevd % under daglig gjennomsnitt", "best_price_min_distance_from_avg": "Minimumsavstand: Påkrevd % under daglig gjennomsnitt",
"best_price_min_volatility": "Minimum volatilitetsfilter", "best_price_min_volatility": "Minimum volatilitetsfilter",
"best_price_max_level": "Prisnivåfilter (valgfritt)" "best_price_max_level": "Prisnivåfilter (valgfritt)",
"enable_min_periods_best": "Prøv å oppnå minimum antall perioder",
"min_periods_best": "Minimum antall perioder",
"relaxation_step_best": "Avslappingstrinn"
}, },
"data_description": { "data_description": {
"best_price_min_volatility": "Vis kun beste prisperioder når dagens volatilitet oppfyller eller overskrider dette nivået. Standard: 'Lav' (vis uavhengig av volatilitet) - batterioptimalisering er nyttig selv ved små prisvariasjoner. Velg 'Moderat'/'Høy' for kun å vise perioder på mer volatile dager.", "best_price_min_volatility": "Vis kun beste prisperioder når periodens interne prisvolatilitet (prisspennet innenfor perioden) oppfyller eller overskrider dette nivået. Standard: 'Lav' (vis perioder med hvilket som helst volatilitetsnivå) - gjør det mulig å finne billige perioder selv om prisene er stabile. Velg 'Moderat'/'Høy' for kun å vise perioder med betydelige prisvariasjoner, noe som kan indikere mer dynamiske prismuligheter.",
"best_price_max_level": "Vis kun beste prisperioder hvis minst ett intervall i dag har et prisnivå ≤ valgt verdi. Fungerer med OG-logikk: volatilitetsfilter (hvis satt) OG nivåfilter må begge være oppfylt. Nyttig for å unngå batterilading på dyre dager. Velg 'Alle' for å deaktivere dette filteret." "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."
}, },
"submit": "Neste til steg 5" "submit": "Neste til steg 5"
}, },
@ -121,11 +127,17 @@
"peak_price_flex": "Fleksibilitet: Maksimum % under maksimumspris (negativ verdi)", "peak_price_flex": "Fleksibilitet: Maksimum % under maksimumspris (negativ verdi)",
"peak_price_min_distance_from_avg": "Minimumsavstand: Påkrevd % over daglig gjennomsnitt", "peak_price_min_distance_from_avg": "Minimumsavstand: Påkrevd % over daglig gjennomsnitt",
"peak_price_min_volatility": "Minimum volatilitetsfilter", "peak_price_min_volatility": "Minimum volatilitetsfilter",
"peak_price_min_level": "Prisnivåfilter (valgfritt)" "peak_price_min_level": "Prisnivåfilter (valgfritt)",
"enable_min_periods_peak": "Prøv å oppnå minimum antall perioder",
"min_periods_peak": "Minimum antall perioder",
"relaxation_step_peak": "Avslappingstrinn"
}, },
"data_description": { "data_description": {
"peak_price_min_volatility": "Vis kun topprisperioder når dagens volatilitet oppfyller eller overskrider dette nivået. Standard: 'Lav' (vis uavhengig av volatilitet) - toppadvarsler er relevante selv ved lav spredning siden unngåelse av dyre timer alltid er viktig. Velg 'Moderat'/'Høy' for kun å vise topper på volatile dager.", "peak_price_min_volatility": "Vis kun topprisperioder når periodens interne prisvolatilitet (prisspennet innenfor perioden) oppfyller eller overskrider dette nivået. Standard: 'Lav' (vis perioder med hvilket som helst volatilitetsnivå) - gjør det mulig å identifisere dyre perioder selv om prisene er stabile. Velg 'Moderat'/'Høy' for kun å vise perioder med betydelige prisvariasjoner, noe som kan indikere mer presserende behov for å unngå disse tidspunktene.",
"peak_price_min_level": "Vis kun topprisperioder hvis minst ett intervall i dag har et prisnivå ≥ valgt verdi. Fungerer med OG-logikk: volatilitetsfilter (hvis satt) OG nivåfilter må begge være oppfylt. Vanligvis satt til 'Alle' siden toppperioder er relative til dagen. Velg 'Alle' for å deaktivere dette filteret." "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."
}, },
"submit": "Neste til steg 6" "submit": "Neste til steg 6"
}, },

View file

@ -105,11 +105,17 @@
"best_price_flex": "Flexibiliteit: Maximaal % boven minimumprijs", "best_price_flex": "Flexibiliteit: Maximaal % boven minimumprijs",
"best_price_min_distance_from_avg": "Minimale afstand: Vereist % onder dagelijks gemiddelde", "best_price_min_distance_from_avg": "Minimale afstand: Vereist % onder dagelijks gemiddelde",
"best_price_min_volatility": "Minimum volatiliteitsfilter", "best_price_min_volatility": "Minimum volatiliteitsfilter",
"best_price_max_level": "Prijsniveaufilter (Optioneel)" "best_price_max_level": "Prijsniveaufilter (Optioneel)",
"enable_min_periods_best": "Probeer minimum aantal periodes te bereiken",
"min_periods_best": "Minimum aantal periodes",
"relaxation_step_best": "Ontspanningsstap"
}, },
"data_description": { "data_description": {
"best_price_min_volatility": "Toon alleen beste prijsperiodes wanneer de volatiliteit van vandaag dit niveau bereikt of overschrijdt. Standaard: 'Laag' (toon ongeacht volatiliteit) - batterijoptimalisatie is nuttig zelfs bij kleine prijsvariaties. Selecteer 'Matig'/'Hoog' om periodes alleen op meer volatiele dagen te tonen.", "best_price_min_volatility": "Toon alleen beste prijsperiodes wanneer de interne prijsvolatiliteit van de periode (prijsspanne binnen de periode) dit niveau bereikt of overschrijdt. Standaard: 'Laag' (toon periodes met elk volatiliteitsniveau) - maakt het mogelijk om goedkope periodes te vinden zelfs als de prijzen stabiel zijn. Selecteer 'Matig'/'Hoog' om alleen periodes met significante prijsvariaties te tonen, wat kan wijzen op meer dynamische prijsmogelijkheden.",
"best_price_max_level": "Toon alleen beste prijsperiodes als minstens één interval vandaag een prijsniveau ≤ geselecteerde waarde heeft. Werkt met EN-logica: volatiliteitsfilter (indien ingesteld) EN niveaufilter moeten beide voldaan zijn. Nuttig om batterij laden op dure dagen te vermijden. Selecteer 'Alle' om dit filter uit te schakelen." "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."
}, },
"submit": "Volgende naar stap 5" "submit": "Volgende naar stap 5"
}, },
@ -121,11 +127,17 @@
"peak_price_flex": "Flexibiliteit: Maximaal % onder maximumprijs (negatieve waarde)", "peak_price_flex": "Flexibiliteit: Maximaal % onder maximumprijs (negatieve waarde)",
"peak_price_min_distance_from_avg": "Minimale afstand: Vereist % boven dagelijks gemiddelde", "peak_price_min_distance_from_avg": "Minimale afstand: Vereist % boven dagelijks gemiddelde",
"peak_price_min_volatility": "Minimum volatiliteitsfilter", "peak_price_min_volatility": "Minimum volatiliteitsfilter",
"peak_price_min_level": "Prijsniveaufilter (Optioneel)" "peak_price_min_level": "Prijsniveaufilter (Optioneel)",
"enable_min_periods_peak": "Probeer minimum aantal periodes te bereiken",
"min_periods_peak": "Minimum aantal periodes",
"relaxation_step_peak": "Ontspanningsstap"
}, },
"data_description": { "data_description": {
"peak_price_min_volatility": "Toon alleen piekprijsperiodes wanneer de volatiliteit van vandaag dit niveau bereikt of overschrijdt. Standaard: 'Laag' (toon ongeacht volatiliteit) - piekwaarschuwingen zijn relevant zelfs bij lage spreiding omdat vermijding van dure uren altijd belangrijk is. Selecteer 'Matig'/'Hoog' om alleen pieken op volatiele dagen te tonen.", "peak_price_min_volatility": "Toon alleen piekprijsperiodes wanneer de interne prijsvolatiliteit van de periode (prijsspanne binnen de periode) dit niveau bereikt of overschrijdt. Standaard: 'Laag' (toon periodes met elk volatiliteitsniveau) - maakt het mogelijk om dure periodes te identificeren zelfs als de prijzen stabiel zijn. Selecteer 'Matig'/'Hoog' om alleen periodes met significante prijsvariaties te tonen, wat kan wijzen op een urgenter noodzaak om deze tijden te vermijden.",
"peak_price_min_level": "Toon alleen piekprijsperiodes als minstens één interval vandaag een prijsniveau ≥ geselecteerde waarde heeft. Werkt met EN-logica: volatiliteitsfilter (indien ingesteld) EN niveaufilter moeten beide voldaan zijn. Meestal ingesteld op 'Alle' omdat piekperiodes relatief zijn aan de dag. Selecteer 'Alle' om dit filter uit te schakelen." "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."
}, },
"submit": "Volgende naar stap 6" "submit": "Volgende naar stap 6"
}, },

View file

@ -105,11 +105,17 @@
"best_price_flex": "Flexibilitet: Maximalt % över minimumpris", "best_price_flex": "Flexibilitet: Maximalt % över minimumpris",
"best_price_min_distance_from_avg": "Minimiavstånd: Krävd % under dagligt genomsnitt", "best_price_min_distance_from_avg": "Minimiavstånd: Krävd % under dagligt genomsnitt",
"best_price_min_volatility": "Minimum volatilitetsfilter", "best_price_min_volatility": "Minimum volatilitetsfilter",
"best_price_max_level": "Prisnivåfilter (Valfritt)" "best_price_max_level": "Prisnivåfilter (Valfritt)",
"enable_min_periods_best": "Försök uppnå minsta antal perioder",
"min_periods_best": "Minsta antal perioder",
"relaxation_step_best": "Avslappningssteg"
}, },
"data_description": { "data_description": {
"best_price_min_volatility": "Visa endast bästa prisperioder när dagens volatilitet uppfyller eller överskrider denna nivå. Standard: 'Låg' (visa oavsett volatilitet) - batterioptimering är användbart även vid små prisvariationer. Välj 'Måttlig'/'Hög' för att endast visa perioder på mer volatila dagar.", "best_price_min_volatility": "Visa endast bästa prisperioder när periodens interna prisvolatilitet (prisspann inom perioden) uppfyller eller överskrider denna nivå. Standard: 'Låg' (visa perioder med valfri volatilitetsnivå) - möjliggör att hitta billiga perioder även om priserna är stabila. Välj 'Måttlig'/'Hög' för att endast visa perioder med betydande prisvariationer, vilket kan indikera mer dynamiska prismöjligheter.",
"best_price_max_level": "Visa endast bästa prisperioder om minst ett intervall idag har en prisnivå ≤ valt värde. Fungerar med OCH-logik: volatilitetsfilter (om inställt) OCH nivåfilter måste båda vara uppfyllda. Användbart för att undvika batteriladdning på dyra dagar. Välj 'Alla' för att inaktivera detta filter." "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."
}, },
"submit": "Nästa till steg 5" "submit": "Nästa till steg 5"
}, },
@ -121,11 +127,17 @@
"peak_price_flex": "Flexibilitet: Maximalt % under maximumpris (negativt värde)", "peak_price_flex": "Flexibilitet: Maximalt % under maximumpris (negativt värde)",
"peak_price_min_distance_from_avg": "Minimiavstånd: Krävd % över dagligt genomsnitt", "peak_price_min_distance_from_avg": "Minimiavstånd: Krävd % över dagligt genomsnitt",
"peak_price_min_volatility": "Minimum volatilitetsfilter", "peak_price_min_volatility": "Minimum volatilitetsfilter",
"peak_price_min_level": "Prisnivåfilter (Valfritt)" "peak_price_min_level": "Prisnivåfilter (Valfritt)",
"enable_min_periods_peak": "Försök uppnå minsta antal perioder",
"min_periods_peak": "Minsta antal perioder",
"relaxation_step_peak": "Avslappningssteg"
}, },
"data_description": { "data_description": {
"peak_price_min_volatility": "Visa endast topprisperioder när dagens volatilitet uppfyller eller överskrider denna nivå. Standard: 'Låg' (visa oavsett volatilitet) - toppvarningar är relevanta även vid låg spridning eftersom undvikande av dyra timmar alltid är viktigt. Välj 'Måttlig'/'Hög' för att endast visa toppar på volatila dagar.", "peak_price_min_volatility": "Visa endast topprisperioder om de har en intern prisvolatilitet (prisspann inom perioden) som uppfyller eller överskrider denna nivå. Standard: 'Låg' (visa oavsett periodens volatilitet) - toppvarningar är relevanta även vid låg intern spridning. Högre volatilitet inom en period kan indikera mer brådskande behov att undvika dessa tider (eftersom priserna varierar kraftigt även inom den korta perioden).",
"peak_price_min_level": "Visa endast topprisperioder om minst ett intervall idag har en prisnivå ≥ valt värde. Fungerar med OCH-logik: volatilitetsfilter (om inställt) OCH nivåfilter måste båda vara uppfyllda. Vanligtvis inställt på 'Alla' eftersom toppperioder är relativa till dagen. Välj 'Alla' för att inaktivera detta filter." "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."
}, },
"submit": "Nästa till steg 6" "submit": "Nästa till steg 6"
}, },