mirror of
https://github.com/jpawlowski/hass.tibber_prices.git
synced 2026-03-30 13:23:41 +00:00
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:
parent
9640b041e0
commit
40a335dabe
10 changed files with 1051 additions and 123 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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"),
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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]:
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue