mirror of
https://github.com/jpawlowski/hass.tibber_prices.git
synced 2026-07-28 17:46:49 +00:00
Compare commits
4 commits
4ddd19b132
...
779e22a84e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
779e22a84e | ||
|
|
9e1ba10f0b | ||
|
|
a8d5230531 | ||
|
|
796eb4b422 |
23 changed files with 509 additions and 83 deletions
|
|
@ -119,6 +119,19 @@ def get_price_intervals_attributes(
|
||||||
if not filtered_periods:
|
if not filtered_periods:
|
||||||
return build_no_periods_result(time=time)
|
return build_no_periods_result(time=time)
|
||||||
|
|
||||||
|
# Recalculate position metadata after filtering (coordinator stamped values include yesterday)
|
||||||
|
# Use shallow copies so coordinator dicts are not mutated
|
||||||
|
total_filtered = len(filtered_periods)
|
||||||
|
filtered_periods = [
|
||||||
|
period
|
||||||
|
| {
|
||||||
|
"period_position": i,
|
||||||
|
"period_count_total": total_filtered,
|
||||||
|
"periods_remaining": total_filtered - i,
|
||||||
|
}
|
||||||
|
for i, period in enumerate(filtered_periods, 1)
|
||||||
|
]
|
||||||
|
|
||||||
# Find current or next period based on current time
|
# Find current or next period based on current time
|
||||||
current_period = None
|
current_period = None
|
||||||
|
|
||||||
|
|
@ -251,8 +264,8 @@ def add_detail_attributes(attributes: dict, current_period: dict) -> None:
|
||||||
attributes["period_interval_count"] = current_period["period_interval_count"]
|
attributes["period_interval_count"] = current_period["period_interval_count"]
|
||||||
if "period_position" in current_period:
|
if "period_position" in current_period:
|
||||||
attributes["period_position"] = current_period["period_position"]
|
attributes["period_position"] = current_period["period_position"]
|
||||||
if "periods_total" in current_period:
|
if "period_count_total" in current_period:
|
||||||
attributes["periods_total"] = current_period["periods_total"]
|
attributes["period_count_total"] = current_period["period_count_total"]
|
||||||
if "periods_remaining" in current_period:
|
if "periods_remaining" in current_period:
|
||||||
attributes["periods_remaining"] = current_period["periods_remaining"]
|
attributes["periods_remaining"] = current_period["periods_remaining"]
|
||||||
|
|
||||||
|
|
@ -298,9 +311,8 @@ def add_period_count_attributes(
|
||||||
count_tomorrow += 1
|
count_tomorrow += 1
|
||||||
|
|
||||||
_ = now # used for clarity only
|
_ = now # used for clarity only
|
||||||
if count_today > 0 or count_tomorrow > 0:
|
attributes["period_count_today"] = count_today
|
||||||
attributes["period_count_today"] = count_today
|
attributes["period_count_tomorrow"] = count_tomorrow
|
||||||
attributes["period_count_tomorrow"] = count_tomorrow
|
|
||||||
|
|
||||||
|
|
||||||
def add_relaxation_attributes(attributes: dict, current_period: dict) -> None:
|
def add_relaxation_attributes(attributes: dict, current_period: dict) -> None:
|
||||||
|
|
@ -324,13 +336,11 @@ def add_calculation_summary_attributes(attributes: dict, period_metadata: dict)
|
||||||
"""
|
"""
|
||||||
Add calculation summary attributes (priority 7).
|
Add calculation summary attributes (priority 7).
|
||||||
|
|
||||||
Provides diagnostic visibility into the period calculation: how many periods
|
Provides diagnostic visibility into the period calculation: whether any flat days
|
||||||
were requested vs. found, whether any flat days triggered adaptive min_periods,
|
triggered adaptive min_periods, and whether relaxation could not satisfy all days.
|
||||||
and whether relaxation could not satisfy all days.
|
|
||||||
|
|
||||||
Only adds non-default/interesting values to avoid clutter:
|
Only adds non-default/interesting values to avoid clutter:
|
||||||
- min_periods_configured: always added (useful reference for automations)
|
- min_periods_configured: always added (useful reference for automations)
|
||||||
- periods_found_total: always added
|
|
||||||
- flat_days_detected: only when > 0 (explains why fewer periods than configured)
|
- flat_days_detected: only when > 0 (explains why fewer periods than configured)
|
||||||
- relaxation_incomplete: only when True (diagnostic flag for troubleshooting)
|
- relaxation_incomplete: only when True (diagnostic flag for troubleshooting)
|
||||||
|
|
||||||
|
|
@ -342,9 +352,6 @@ def add_calculation_summary_attributes(attributes: dict, period_metadata: dict)
|
||||||
if "min_periods_requested" in relaxation_meta:
|
if "min_periods_requested" in relaxation_meta:
|
||||||
attributes["min_periods_configured"] = relaxation_meta["min_periods_requested"]
|
attributes["min_periods_configured"] = relaxation_meta["min_periods_requested"]
|
||||||
|
|
||||||
if "periods_found" in relaxation_meta:
|
|
||||||
attributes["periods_found_total"] = relaxation_meta["periods_found"]
|
|
||||||
|
|
||||||
flat_days = relaxation_meta.get("flat_days_detected", 0)
|
flat_days = relaxation_meta.get("flat_days_detected", 0)
|
||||||
if flat_days > 0:
|
if flat_days > 0:
|
||||||
attributes["flat_days_detected"] = flat_days
|
attributes["flat_days_detected"] = flat_days
|
||||||
|
|
@ -414,10 +421,10 @@ def build_final_attributes_simple(
|
||||||
2. Core decision attributes (level, rating_level, rating_difference_%)
|
2. Core decision attributes (level, rating_level, rating_difference_%)
|
||||||
3. Price statistics (price_mean, price_median, price_min, price_max, price_spread, volatility)
|
3. Price statistics (price_mean, price_median, price_min, price_max, price_spread, volatility)
|
||||||
4. Price differences (period_price_diff_from_daily_min, period_price_diff_from_daily_min_%)
|
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)
|
5. Detail information (period_interval_count, period_position, period_count_total, periods_remaining)
|
||||||
6. Relaxation information (relaxation_active, relaxation_level, relaxation_threshold_original_%,
|
6. Relaxation information (relaxation_active, relaxation_level, relaxation_threshold_original_%,
|
||||||
relaxation_threshold_applied_%) - only if current period was relaxed
|
relaxation_threshold_applied_%) - only if current period was relaxed
|
||||||
7. Calculation summary (min_periods_configured, periods_found_total, flat_days_detected,
|
7. Calculation summary (min_periods_configured, flat_days_detected,
|
||||||
relaxation_incomplete) - diagnostic info about the overall calculation
|
relaxation_incomplete) - diagnostic info about the overall calculation
|
||||||
8. Meta information (periods list)
|
8. Meta information (periods list)
|
||||||
|
|
||||||
|
|
@ -464,7 +471,7 @@ def build_final_attributes_simple(
|
||||||
# 6. Relaxation information (only if current period was relaxed)
|
# 6. Relaxation information (only if current period was relaxed)
|
||||||
add_relaxation_attributes(attributes, current_period)
|
add_relaxation_attributes(attributes, current_period)
|
||||||
|
|
||||||
# 7. Calculation summary (diagnostic: min_periods_configured, periods_found_total, etc.)
|
# 7. Calculation summary (diagnostic: min_periods_configured, flat_days_detected, etc.)
|
||||||
if period_metadata:
|
if period_metadata:
|
||||||
add_calculation_summary_attributes(attributes, period_metadata)
|
add_calculation_summary_attributes(attributes, period_metadata)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -64,7 +64,6 @@ class TibberPricesBinarySensor(TibberPricesEntity, BinarySensorEntity, RestoreEn
|
||||||
"relaxation_threshold_applied_%",
|
"relaxation_threshold_applied_%",
|
||||||
# Calculation Summary (diagnostic, changes daily → not useful in history)
|
# Calculation Summary (diagnostic, changes daily → not useful in history)
|
||||||
"min_periods_configured",
|
"min_periods_configured",
|
||||||
"periods_found_total",
|
|
||||||
"flat_days_detected",
|
"flat_days_detected",
|
||||||
"relaxation_incomplete",
|
"relaxation_incomplete",
|
||||||
# Redundant/Derived
|
# Redundant/Derived
|
||||||
|
|
@ -73,7 +72,7 @@ class TibberPricesBinarySensor(TibberPricesEntity, BinarySensorEntity, RestoreEn
|
||||||
"rating_difference_%",
|
"rating_difference_%",
|
||||||
"period_price_diff_from_daily_min",
|
"period_price_diff_from_daily_min",
|
||||||
"period_price_diff_from_daily_min_%",
|
"period_price_diff_from_daily_min_%",
|
||||||
"periods_total",
|
"period_count_total",
|
||||||
"periods_remaining",
|
"periods_remaining",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -104,7 +104,7 @@ class PeriodSummary(TypedDict, total=False):
|
||||||
# Detail information (priority 5)
|
# Detail information (priority 5)
|
||||||
period_interval_count: int # Number of intervals in period
|
period_interval_count: int # Number of intervals in period
|
||||||
period_position: int # Period position (1-based)
|
period_position: int # Period position (1-based)
|
||||||
periods_total: int # Total number of periods
|
period_count_total: int # Total number of periods
|
||||||
periods_remaining: int # Remaining periods after this one
|
periods_remaining: int # Remaining periods after this one
|
||||||
|
|
||||||
# Relaxation information (priority 6 - only if period was relaxed)
|
# Relaxation information (priority 6 - only if period was relaxed)
|
||||||
|
|
@ -125,7 +125,7 @@ class PeriodAttributes(BaseAttributes, total=False):
|
||||||
2. Core decision attributes (level, rating_level, rating_difference_%)
|
2. Core decision attributes (level, rating_level, rating_difference_%)
|
||||||
3. Price statistics (price_mean, price_median, price_min, price_max, price_spread, volatility)
|
3. Price statistics (price_mean, price_median, price_min, price_max, price_spread, volatility)
|
||||||
4. Price comparison (period_price_diff_from_daily_min, period_price_diff_from_daily_min_%)
|
4. Price comparison (period_price_diff_from_daily_min, period_price_diff_from_daily_min_%)
|
||||||
5. Detail information (period_interval_count, period_position, periods_total, periods_remaining)
|
5. Detail information (period_interval_count, period_position, period_count_total, periods_remaining)
|
||||||
6. Relaxation information (only if period was relaxed)
|
6. Relaxation information (only if period was relaxed)
|
||||||
7. Meta information (periods list)
|
7. Meta information (periods list)
|
||||||
"""
|
"""
|
||||||
|
|
@ -155,7 +155,7 @@ class PeriodAttributes(BaseAttributes, total=False):
|
||||||
# Detail information (priority 5)
|
# Detail information (priority 5)
|
||||||
period_interval_count: int # Number of intervals in current/next period
|
period_interval_count: int # Number of intervals in current/next period
|
||||||
period_position: int # Period position (1-based)
|
period_position: int # Period position (1-based)
|
||||||
periods_total: int # Total number of periods found
|
period_count_total: int # Total number of periods found
|
||||||
periods_remaining: int # Remaining periods after current/next one
|
periods_remaining: int # Remaining periods after current/next one
|
||||||
|
|
||||||
# Relaxation information (priority 6 - only if period was relaxed)
|
# Relaxation information (priority 6 - only if period was relaxed)
|
||||||
|
|
|
||||||
|
|
@ -378,7 +378,6 @@ class TibberPricesOptionsFlowHandler(OptionsFlow):
|
||||||
|
|
||||||
# Load template and connector from common section
|
# Load template and connector from common section
|
||||||
template = await async_get_translation(self.hass, ["common", "override_warning_template"], language)
|
template = await async_get_translation(self.hass, ["common", "override_warning_template"], language)
|
||||||
_LOGGER.debug("Loaded template: %s", template)
|
|
||||||
if template:
|
if template:
|
||||||
translations["override_warning_template"] = template
|
translations["override_warning_template"] = template
|
||||||
|
|
||||||
|
|
@ -645,8 +644,8 @@ class TibberPricesOptionsFlowHandler(OptionsFlow):
|
||||||
placeholders = self._get_entity_warning_placeholders("best_price")
|
placeholders = self._get_entity_warning_placeholders("best_price")
|
||||||
placeholders.update(self._get_override_warning_placeholder("best_price", overrides))
|
placeholders.update(self._get_override_warning_placeholder("best_price", overrides))
|
||||||
|
|
||||||
# Load translations for override warnings
|
# Load translations for override warnings only when overrides are active
|
||||||
override_translations = await self._get_override_translations()
|
override_translations = await self._get_override_translations() if overrides else {}
|
||||||
|
|
||||||
return self.async_show_form(
|
return self.async_show_form(
|
||||||
step_id="best_price",
|
step_id="best_price",
|
||||||
|
|
@ -717,8 +716,8 @@ class TibberPricesOptionsFlowHandler(OptionsFlow):
|
||||||
placeholders = self._get_entity_warning_placeholders("peak_price")
|
placeholders = self._get_entity_warning_placeholders("peak_price")
|
||||||
placeholders.update(self._get_override_warning_placeholder("peak_price", overrides))
|
placeholders.update(self._get_override_warning_placeholder("peak_price", overrides))
|
||||||
|
|
||||||
# Load translations for override warnings
|
# Load translations for override warnings only when overrides are active
|
||||||
override_translations = await self._get_override_translations()
|
override_translations = await self._get_override_translations() if overrides else {}
|
||||||
|
|
||||||
return self.async_show_form(
|
return self.async_show_form(
|
||||||
step_id="peak_price",
|
step_id="peak_price",
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,8 @@ from custom_components.tibber_prices.const import (
|
||||||
CONF_BEST_PRICE_MAX_LEVEL_GAP_COUNT,
|
CONF_BEST_PRICE_MAX_LEVEL_GAP_COUNT,
|
||||||
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_SEGMENT_FORCING,
|
||||||
|
CONF_BEST_PRICE_SEGMENT_MIN_PERIODS,
|
||||||
CONF_CURRENCY_DISPLAY_MODE,
|
CONF_CURRENCY_DISPLAY_MODE,
|
||||||
CONF_ENABLE_MIN_PERIODS_BEST,
|
CONF_ENABLE_MIN_PERIODS_BEST,
|
||||||
CONF_ENABLE_MIN_PERIODS_PEAK,
|
CONF_ENABLE_MIN_PERIODS_PEAK,
|
||||||
|
|
@ -34,6 +36,8 @@ from custom_components.tibber_prices.const import (
|
||||||
CONF_PEAK_PRICE_MIN_DISTANCE_FROM_AVG,
|
CONF_PEAK_PRICE_MIN_DISTANCE_FROM_AVG,
|
||||||
CONF_PEAK_PRICE_MIN_LEVEL,
|
CONF_PEAK_PRICE_MIN_LEVEL,
|
||||||
CONF_PEAK_PRICE_MIN_PERIOD_LENGTH,
|
CONF_PEAK_PRICE_MIN_PERIOD_LENGTH,
|
||||||
|
CONF_PEAK_PRICE_SEGMENT_FORCING,
|
||||||
|
CONF_PEAK_PRICE_SEGMENT_MIN_PERIODS,
|
||||||
CONF_PRICE_LEVEL_GAP_TOLERANCE,
|
CONF_PRICE_LEVEL_GAP_TOLERANCE,
|
||||||
CONF_PRICE_RATING_GAP_TOLERANCE,
|
CONF_PRICE_RATING_GAP_TOLERANCE,
|
||||||
CONF_PRICE_RATING_HYSTERESIS,
|
CONF_PRICE_RATING_HYSTERESIS,
|
||||||
|
|
@ -63,6 +67,8 @@ from custom_components.tibber_prices.const import (
|
||||||
DEFAULT_BEST_PRICE_MAX_LEVEL_GAP_COUNT,
|
DEFAULT_BEST_PRICE_MAX_LEVEL_GAP_COUNT,
|
||||||
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_SEGMENT_FORCING,
|
||||||
|
DEFAULT_BEST_PRICE_SEGMENT_MIN_PERIODS,
|
||||||
DEFAULT_ENABLE_MIN_PERIODS_BEST,
|
DEFAULT_ENABLE_MIN_PERIODS_BEST,
|
||||||
DEFAULT_ENABLE_MIN_PERIODS_PEAK,
|
DEFAULT_ENABLE_MIN_PERIODS_PEAK,
|
||||||
DEFAULT_EXTENDED_DESCRIPTIONS,
|
DEFAULT_EXTENDED_DESCRIPTIONS,
|
||||||
|
|
@ -76,6 +82,8 @@ from custom_components.tibber_prices.const import (
|
||||||
DEFAULT_PEAK_PRICE_MIN_DISTANCE_FROM_AVG,
|
DEFAULT_PEAK_PRICE_MIN_DISTANCE_FROM_AVG,
|
||||||
DEFAULT_PEAK_PRICE_MIN_LEVEL,
|
DEFAULT_PEAK_PRICE_MIN_LEVEL,
|
||||||
DEFAULT_PEAK_PRICE_MIN_PERIOD_LENGTH,
|
DEFAULT_PEAK_PRICE_MIN_PERIOD_LENGTH,
|
||||||
|
DEFAULT_PEAK_PRICE_SEGMENT_FORCING,
|
||||||
|
DEFAULT_PEAK_PRICE_SEGMENT_MIN_PERIODS,
|
||||||
DEFAULT_PRICE_LEVEL_GAP_TOLERANCE,
|
DEFAULT_PRICE_LEVEL_GAP_TOLERANCE,
|
||||||
DEFAULT_PRICE_RATING_GAP_TOLERANCE,
|
DEFAULT_PRICE_RATING_GAP_TOLERANCE,
|
||||||
DEFAULT_PRICE_RATING_HYSTERESIS,
|
DEFAULT_PRICE_RATING_HYSTERESIS,
|
||||||
|
|
@ -116,6 +124,7 @@ from custom_components.tibber_prices.const import (
|
||||||
MAX_PRICE_TREND_STRONGLY_FALLING,
|
MAX_PRICE_TREND_STRONGLY_FALLING,
|
||||||
MAX_PRICE_TREND_STRONGLY_RISING,
|
MAX_PRICE_TREND_STRONGLY_RISING,
|
||||||
MAX_RELAXATION_ATTEMPTS,
|
MAX_RELAXATION_ATTEMPTS,
|
||||||
|
MAX_SEGMENT_MIN_PERIODS,
|
||||||
MAX_VOLATILITY_THRESHOLD_HIGH,
|
MAX_VOLATILITY_THRESHOLD_HIGH,
|
||||||
MAX_VOLATILITY_THRESHOLD_MODERATE,
|
MAX_VOLATILITY_THRESHOLD_MODERATE,
|
||||||
MAX_VOLATILITY_THRESHOLD_VERY_HIGH,
|
MAX_VOLATILITY_THRESHOLD_VERY_HIGH,
|
||||||
|
|
@ -655,6 +664,12 @@ def get_best_price_schema(
|
||||||
extension_settings.get(CONF_BEST_PRICE_MAX_EXTENSION_INTERVALS, DEFAULT_BEST_PRICE_MAX_EXTENSION_INTERVALS)
|
extension_settings.get(CONF_BEST_PRICE_MAX_EXTENSION_INTERVALS, DEFAULT_BEST_PRICE_MAX_EXTENSION_INTERVALS)
|
||||||
)
|
)
|
||||||
geometric_flex_best = int(extension_settings.get(CONF_BEST_PRICE_GEOMETRIC_FLEX, DEFAULT_BEST_PRICE_GEOMETRIC_FLEX))
|
geometric_flex_best = int(extension_settings.get(CONF_BEST_PRICE_GEOMETRIC_FLEX, DEFAULT_BEST_PRICE_GEOMETRIC_FLEX))
|
||||||
|
segment_forcing_best = bool(
|
||||||
|
extension_settings.get(CONF_BEST_PRICE_SEGMENT_FORCING, DEFAULT_BEST_PRICE_SEGMENT_FORCING)
|
||||||
|
)
|
||||||
|
segment_min_periods_best = int(
|
||||||
|
extension_settings.get(CONF_BEST_PRICE_SEGMENT_MIN_PERIODS, DEFAULT_BEST_PRICE_SEGMENT_MIN_PERIODS)
|
||||||
|
)
|
||||||
|
|
||||||
# Build section schemas with optional override warnings
|
# Build section schemas with optional override warnings
|
||||||
period_warning = get_section_override_warning("best_price", "period_settings", overrides, translations) or {}
|
period_warning = get_section_override_warning("best_price", "period_settings", overrides, translations) or {}
|
||||||
|
|
@ -806,6 +821,21 @@ def get_best_price_schema(
|
||||||
mode=NumberSelectorMode.SLIDER,
|
mode=NumberSelectorMode.SLIDER,
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
|
vol.Optional(
|
||||||
|
CONF_BEST_PRICE_SEGMENT_FORCING,
|
||||||
|
default=segment_forcing_best,
|
||||||
|
): BooleanSelector(selector.BooleanSelectorConfig()),
|
||||||
|
vol.Optional(
|
||||||
|
CONF_BEST_PRICE_SEGMENT_MIN_PERIODS,
|
||||||
|
default=segment_min_periods_best,
|
||||||
|
): NumberSelector(
|
||||||
|
NumberSelectorConfig(
|
||||||
|
min=1,
|
||||||
|
max=MAX_SEGMENT_MIN_PERIODS,
|
||||||
|
step=1,
|
||||||
|
mode=NumberSelectorMode.SLIDER,
|
||||||
|
)
|
||||||
|
),
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
{"collapsed": True},
|
{"collapsed": True},
|
||||||
|
|
@ -858,6 +888,12 @@ def get_peak_price_schema(
|
||||||
extension_settings.get(CONF_PEAK_PRICE_MAX_EXTENSION_INTERVALS, DEFAULT_PEAK_PRICE_MAX_EXTENSION_INTERVALS)
|
extension_settings.get(CONF_PEAK_PRICE_MAX_EXTENSION_INTERVALS, DEFAULT_PEAK_PRICE_MAX_EXTENSION_INTERVALS)
|
||||||
)
|
)
|
||||||
geometric_flex_peak = int(extension_settings.get(CONF_PEAK_PRICE_GEOMETRIC_FLEX, DEFAULT_PEAK_PRICE_GEOMETRIC_FLEX))
|
geometric_flex_peak = int(extension_settings.get(CONF_PEAK_PRICE_GEOMETRIC_FLEX, DEFAULT_PEAK_PRICE_GEOMETRIC_FLEX))
|
||||||
|
segment_forcing_peak = bool(
|
||||||
|
extension_settings.get(CONF_PEAK_PRICE_SEGMENT_FORCING, DEFAULT_PEAK_PRICE_SEGMENT_FORCING)
|
||||||
|
)
|
||||||
|
segment_min_periods_peak = int(
|
||||||
|
extension_settings.get(CONF_PEAK_PRICE_SEGMENT_MIN_PERIODS, DEFAULT_PEAK_PRICE_SEGMENT_MIN_PERIODS)
|
||||||
|
)
|
||||||
|
|
||||||
# Build section schemas with optional override warnings
|
# Build section schemas with optional override warnings
|
||||||
period_warning = get_section_override_warning("peak_price", "period_settings", overrides, translations) or {}
|
period_warning = get_section_override_warning("peak_price", "period_settings", overrides, translations) or {}
|
||||||
|
|
@ -1009,6 +1045,21 @@ def get_peak_price_schema(
|
||||||
mode=NumberSelectorMode.SLIDER,
|
mode=NumberSelectorMode.SLIDER,
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
|
vol.Optional(
|
||||||
|
CONF_PEAK_PRICE_SEGMENT_FORCING,
|
||||||
|
default=segment_forcing_peak,
|
||||||
|
): BooleanSelector(selector.BooleanSelectorConfig()),
|
||||||
|
vol.Optional(
|
||||||
|
CONF_PEAK_PRICE_SEGMENT_MIN_PERIODS,
|
||||||
|
default=segment_min_periods_peak,
|
||||||
|
): NumberSelector(
|
||||||
|
NumberSelectorConfig(
|
||||||
|
min=1,
|
||||||
|
max=MAX_SEGMENT_MIN_PERIODS,
|
||||||
|
step=1,
|
||||||
|
mode=NumberSelectorMode.SLIDER,
|
||||||
|
)
|
||||||
|
),
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
{"collapsed": True},
|
{"collapsed": True},
|
||||||
|
|
|
||||||
|
|
@ -74,6 +74,10 @@ CONF_BEST_PRICE_GEOMETRIC_FLEX = "best_price_geometric_flex"
|
||||||
CONF_PEAK_PRICE_EXTEND_TO_VERY_EXPENSIVE = "peak_price_extend_to_very_expensive"
|
CONF_PEAK_PRICE_EXTEND_TO_VERY_EXPENSIVE = "peak_price_extend_to_very_expensive"
|
||||||
CONF_PEAK_PRICE_MAX_EXTENSION_INTERVALS = "peak_price_max_extension_intervals"
|
CONF_PEAK_PRICE_MAX_EXTENSION_INTERVALS = "peak_price_max_extension_intervals"
|
||||||
CONF_PEAK_PRICE_GEOMETRIC_FLEX = "peak_price_geometric_flex"
|
CONF_PEAK_PRICE_GEOMETRIC_FLEX = "peak_price_geometric_flex"
|
||||||
|
CONF_BEST_PRICE_SEGMENT_FORCING = "best_price_segment_forcing"
|
||||||
|
CONF_BEST_PRICE_SEGMENT_MIN_PERIODS = "best_price_segment_min_periods"
|
||||||
|
CONF_PEAK_PRICE_SEGMENT_FORCING = "peak_price_segment_forcing"
|
||||||
|
CONF_PEAK_PRICE_SEGMENT_MIN_PERIODS = "peak_price_segment_min_periods"
|
||||||
|
|
||||||
ATTRIBUTION = "Data provided by Tibber"
|
ATTRIBUTION = "Data provided by Tibber"
|
||||||
|
|
||||||
|
|
@ -143,6 +147,10 @@ DEFAULT_BEST_PRICE_GEOMETRIC_FLEX = 0 # Default: 0% (disabled); positive int %
|
||||||
DEFAULT_PEAK_PRICE_EXTEND_TO_VERY_EXPENSIVE = False # Default: disabled (opt-in feature)
|
DEFAULT_PEAK_PRICE_EXTEND_TO_VERY_EXPENSIVE = False # Default: disabled (opt-in feature)
|
||||||
DEFAULT_PEAK_PRICE_MAX_EXTENSION_INTERVALS = 4 # Default: up to 4 intervals (1 hour) per side
|
DEFAULT_PEAK_PRICE_MAX_EXTENSION_INTERVALS = 4 # Default: up to 4 intervals (1 hour) per side
|
||||||
DEFAULT_PEAK_PRICE_GEOMETRIC_FLEX = 0 # Default: 0% (disabled); positive int % (e.g. 10 = 10%)
|
DEFAULT_PEAK_PRICE_GEOMETRIC_FLEX = 0 # Default: 0% (disabled); positive int % (e.g. 10 = 10%)
|
||||||
|
DEFAULT_BEST_PRICE_SEGMENT_FORCING = False # Default: disabled (opt-in W-shape feature)
|
||||||
|
DEFAULT_BEST_PRICE_SEGMENT_MIN_PERIODS = 1 # Default: at least 1 period required per segment
|
||||||
|
DEFAULT_PEAK_PRICE_SEGMENT_FORCING = False # Default: disabled (opt-in M-shape feature)
|
||||||
|
DEFAULT_PEAK_PRICE_SEGMENT_MIN_PERIODS = 1 # Default: at least 1 period required per segment
|
||||||
|
|
||||||
# Validation limits (used in GUI schemas and server-side validation)
|
# Validation limits (used in GUI schemas and server-side validation)
|
||||||
# These ensure consistency between frontend and backend validation
|
# These ensure consistency between frontend and backend validation
|
||||||
|
|
@ -153,6 +161,7 @@ MAX_MIN_PERIODS = 10 # Maximum number of minimum periods per day (GUI slider li
|
||||||
MAX_RELAXATION_ATTEMPTS = 12 # Maximum relaxation attempts (GUI slider limit)
|
MAX_RELAXATION_ATTEMPTS = 12 # Maximum relaxation attempts (GUI slider limit)
|
||||||
MAX_EXTENSION_INTERVALS = 12 # Maximum extension intervals per side (GUI slider limit = 3 hours)
|
MAX_EXTENSION_INTERVALS = 12 # Maximum extension intervals per side (GUI slider limit = 3 hours)
|
||||||
MAX_GEOMETRIC_FLEX = 25 # Maximum geometric flex bonus percentage (GUI slider limit)
|
MAX_GEOMETRIC_FLEX = 25 # Maximum geometric flex bonus percentage (GUI slider limit)
|
||||||
|
MAX_SEGMENT_MIN_PERIODS = 5 # Maximum per-segment minimum periods (GUI slider limit)
|
||||||
MIN_PERIOD_LENGTH = 15 # Minimum period length in minutes (1 quarter hour)
|
MIN_PERIOD_LENGTH = 15 # Minimum period length in minutes (1 quarter hour)
|
||||||
MAX_MIN_PERIOD_LENGTH = 180 # Maximum for minimum period length setting (3 hours - realistic for required minimum)
|
MAX_MIN_PERIOD_LENGTH = 180 # Maximum for minimum period length setting (3 hours - realistic for required minimum)
|
||||||
|
|
||||||
|
|
@ -426,9 +435,13 @@ def get_default_options(currency_code: str | None) -> dict[str, Any]:
|
||||||
CONF_BEST_PRICE_EXTEND_TO_VERY_CHEAP: DEFAULT_BEST_PRICE_EXTEND_TO_VERY_CHEAP,
|
CONF_BEST_PRICE_EXTEND_TO_VERY_CHEAP: DEFAULT_BEST_PRICE_EXTEND_TO_VERY_CHEAP,
|
||||||
CONF_BEST_PRICE_MAX_EXTENSION_INTERVALS: DEFAULT_BEST_PRICE_MAX_EXTENSION_INTERVALS,
|
CONF_BEST_PRICE_MAX_EXTENSION_INTERVALS: DEFAULT_BEST_PRICE_MAX_EXTENSION_INTERVALS,
|
||||||
CONF_BEST_PRICE_GEOMETRIC_FLEX: DEFAULT_BEST_PRICE_GEOMETRIC_FLEX,
|
CONF_BEST_PRICE_GEOMETRIC_FLEX: DEFAULT_BEST_PRICE_GEOMETRIC_FLEX,
|
||||||
|
CONF_BEST_PRICE_SEGMENT_FORCING: DEFAULT_BEST_PRICE_SEGMENT_FORCING,
|
||||||
|
CONF_BEST_PRICE_SEGMENT_MIN_PERIODS: DEFAULT_BEST_PRICE_SEGMENT_MIN_PERIODS,
|
||||||
CONF_PEAK_PRICE_EXTEND_TO_VERY_EXPENSIVE: DEFAULT_PEAK_PRICE_EXTEND_TO_VERY_EXPENSIVE,
|
CONF_PEAK_PRICE_EXTEND_TO_VERY_EXPENSIVE: DEFAULT_PEAK_PRICE_EXTEND_TO_VERY_EXPENSIVE,
|
||||||
CONF_PEAK_PRICE_MAX_EXTENSION_INTERVALS: DEFAULT_PEAK_PRICE_MAX_EXTENSION_INTERVALS,
|
CONF_PEAK_PRICE_MAX_EXTENSION_INTERVALS: DEFAULT_PEAK_PRICE_MAX_EXTENSION_INTERVALS,
|
||||||
CONF_PEAK_PRICE_GEOMETRIC_FLEX: DEFAULT_PEAK_PRICE_GEOMETRIC_FLEX,
|
CONF_PEAK_PRICE_GEOMETRIC_FLEX: DEFAULT_PEAK_PRICE_GEOMETRIC_FLEX,
|
||||||
|
CONF_PEAK_PRICE_SEGMENT_FORCING: DEFAULT_PEAK_PRICE_SEGMENT_FORCING,
|
||||||
|
CONF_PEAK_PRICE_SEGMENT_MIN_PERIODS: DEFAULT_PEAK_PRICE_SEGMENT_MIN_PERIODS,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,8 @@ from __future__ import annotations
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
from custom_components.tibber_prices.coordinator.time_service import TibberPricesTimeService
|
from custom_components.tibber_prices.coordinator.time_service import TibberPricesTimeService
|
||||||
|
|
||||||
from .types import TibberPricesPeriodConfig
|
from .types import TibberPricesPeriodConfig
|
||||||
|
|
@ -31,6 +33,7 @@ from .types import TibberPricesThresholdConfig
|
||||||
# Flex limits to prevent degenerate behavior (see docs/development/period-calculation-theory.md)
|
# Flex limits to prevent degenerate behavior (see docs/development/period-calculation-theory.md)
|
||||||
MAX_SAFE_FLEX = 0.50 # 50% - hard cap: above this, period detection becomes unreliable
|
MAX_SAFE_FLEX = 0.50 # 50% - hard cap: above this, period detection becomes unreliable
|
||||||
MAX_OUTLIER_FLEX = 0.25 # 25% - cap for outlier filtering: above this, spike detection too permissive
|
MAX_OUTLIER_FLEX = 0.25 # 25% - cap for outlier filtering: above this, spike detection too permissive
|
||||||
|
MIN_SEGMENT_FORCING_INTERVALS = 8 # Minimum intervals per day half to attempt segment forcing (< 2 hours is too few)
|
||||||
|
|
||||||
|
|
||||||
def calculate_periods(
|
def calculate_periods(
|
||||||
|
|
@ -39,6 +42,7 @@ def calculate_periods(
|
||||||
config: TibberPricesPeriodConfig,
|
config: TibberPricesPeriodConfig,
|
||||||
time: TibberPricesTimeService,
|
time: TibberPricesTimeService,
|
||||||
day_patterns_by_date: dict | None = None,
|
day_patterns_by_date: dict | None = None,
|
||||||
|
time_range: tuple[datetime, datetime] | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Calculate price periods (best or peak) from price data.
|
Calculate price periods (best or peak) from price data.
|
||||||
|
|
@ -61,6 +65,9 @@ def calculate_periods(
|
||||||
min_period_length, threshold_low, and threshold_high.
|
min_period_length, threshold_low, and threshold_high.
|
||||||
time: TibberPricesTimeService instance (required).
|
time: TibberPricesTimeService instance (required).
|
||||||
day_patterns_by_date: Optional dict mapping date → day pattern dict for geometric flex bonus.
|
day_patterns_by_date: Optional dict mapping date → day pattern dict for geometric flex bonus.
|
||||||
|
time_range: Optional (start_inclusive, end_exclusive) window passed through to
|
||||||
|
build_periods(). When set, only intervals within [start, end) are considered
|
||||||
|
as period candidates. Used by Phase 4 segment forcing.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dict with:
|
Dict with:
|
||||||
|
|
@ -168,6 +175,7 @@ def calculate_periods(
|
||||||
level_filter=config.level_filter,
|
level_filter=config.level_filter,
|
||||||
gap_count=config.gap_count,
|
gap_count=config.gap_count,
|
||||||
time=time,
|
time=time,
|
||||||
|
time_range=time_range,
|
||||||
)
|
)
|
||||||
|
|
||||||
_LOGGER.debug(
|
_LOGGER.debug(
|
||||||
|
|
@ -178,6 +186,24 @@ def calculate_periods(
|
||||||
config.level_filter or "None",
|
config.level_filter or "None",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Step 3.5: Segment forcing for W/M-shaped days (opt-in, default disabled)
|
||||||
|
# For days detected as W-shape (DOUBLE_VALLEY for best) or M-shape (DOUBLE_PEAK for peak),
|
||||||
|
# ensures each price valley/peak segment has at least segment_min_periods periods.
|
||||||
|
if config.segment_forcing and day_patterns_by_date:
|
||||||
|
raw_periods = _apply_segment_forcing(
|
||||||
|
all_prices_smoothed,
|
||||||
|
raw_periods,
|
||||||
|
price_context,
|
||||||
|
config,
|
||||||
|
day_patterns_by_date=day_patterns_by_date,
|
||||||
|
time=time,
|
||||||
|
)
|
||||||
|
_LOGGER.debug(
|
||||||
|
"%sAfter segment_forcing: %d periods total",
|
||||||
|
INDENT_L0,
|
||||||
|
len(raw_periods),
|
||||||
|
)
|
||||||
|
|
||||||
# Step 4: Filter by minimum length
|
# Step 4: Filter by minimum length
|
||||||
raw_periods = filter_periods_by_min_length(raw_periods, min_period_length, time=time)
|
raw_periods = filter_periods_by_min_length(raw_periods, min_period_length, time=time)
|
||||||
_LOGGER.debug(
|
_LOGGER.debug(
|
||||||
|
|
@ -264,3 +290,168 @@ def calculate_periods(
|
||||||
"avg_prices": {k.isoformat(): v for k, v in avg_price_by_day.items()},
|
"avg_prices": {k.isoformat(): v for k, v in avg_price_by_day.items()},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Segment forcing helpers ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _period_belongs_to_side(
|
||||||
|
period: list[dict],
|
||||||
|
side_times: set,
|
||||||
|
time: "TibberPricesTimeService",
|
||||||
|
) -> bool:
|
||||||
|
"""Return True if the majority of a period's intervals are in side_times."""
|
||||||
|
if not period:
|
||||||
|
return False
|
||||||
|
in_side = sum(1 for iv in period if time.get_interval_time(iv) in side_times)
|
||||||
|
return in_side * 2 >= len(period)
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_segment_forcing( # noqa: PLR0913
|
||||||
|
all_prices_smoothed: list[dict],
|
||||||
|
periods: list[list[dict]],
|
||||||
|
price_context: dict[str, Any],
|
||||||
|
config: "TibberPricesPeriodConfig",
|
||||||
|
*,
|
||||||
|
day_patterns_by_date: dict,
|
||||||
|
time: "TibberPricesTimeService",
|
||||||
|
) -> list[list[dict]]:
|
||||||
|
"""
|
||||||
|
Force at least segment_min_periods periods per segment for W/M-shaped days.
|
||||||
|
|
||||||
|
For DOUBLE_VALLEY days (best price): splits at the central price peak and
|
||||||
|
ensures each valley side has the required number of periods.
|
||||||
|
For DOUBLE_PEAK days (peak price): splits at the central price valley and
|
||||||
|
ensures each peak side has the required number of periods.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
all_prices_smoothed: Outlier-filtered prices used for period building.
|
||||||
|
periods: Already-found periods from the global build_periods call.
|
||||||
|
price_context: Context dict with reference/average prices + filter settings.
|
||||||
|
config: Period configuration including segment_forcing parameters.
|
||||||
|
day_patterns_by_date: Detected day patterns keyed by date.
|
||||||
|
time: TibberPricesTimeService instance.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Updated periods list with any new segment-forced periods appended.
|
||||||
|
|
||||||
|
"""
|
||||||
|
import logging # noqa: PLC0415
|
||||||
|
|
||||||
|
from .period_building import build_periods # noqa: PLC0415
|
||||||
|
from .types import DAY_PATTERN_DOUBLE_PEAK, DAY_PATTERN_DOUBLE_VALLEY, INDENT_L1, INDENT_L2 # noqa: PLC0415
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__) # noqa: N806
|
||||||
|
|
||||||
|
reverse_sort = config.reverse_sort
|
||||||
|
target_pattern = DAY_PATTERN_DOUBLE_PEAK if reverse_sort else DAY_PATTERN_DOUBLE_VALLEY
|
||||||
|
segment_min_periods = config.segment_min_periods
|
||||||
|
|
||||||
|
merged_periods = list(periods)
|
||||||
|
|
||||||
|
for day_date, day_pattern in day_patterns_by_date.items():
|
||||||
|
if day_pattern is None or day_pattern.get("pattern") != target_pattern:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Collect and sort this day's intervals
|
||||||
|
day_intervals = sorted(
|
||||||
|
(
|
||||||
|
iv
|
||||||
|
for iv in all_prices_smoothed
|
||||||
|
if (t := time.get_interval_time(iv)) is not None and t.date() == day_date
|
||||||
|
),
|
||||||
|
key=time.get_interval_time, # type: ignore[arg-type]
|
||||||
|
)
|
||||||
|
if len(day_intervals) < MIN_SEGMENT_FORCING_INTERVALS: # need at least a few intervals per segment
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Find the central extremum in the middle 50% of the day
|
||||||
|
# DOUBLE_VALLEY → central peak = highest price between the two valleys
|
||||||
|
# DOUBLE_PEAK → central valley = lowest price between the two peaks
|
||||||
|
n = len(day_intervals)
|
||||||
|
middle = day_intervals[n // 4 : 3 * n // 4]
|
||||||
|
if not middle:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not reverse_sort:
|
||||||
|
split_iv = max(middle, key=lambda iv: iv.get("total") or 0)
|
||||||
|
else:
|
||||||
|
split_iv = min(middle, key=lambda iv: iv.get("total") or float("inf"))
|
||||||
|
|
||||||
|
split_time = time.get_interval_time(split_iv)
|
||||||
|
if split_time is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
side_a = [iv for iv in day_intervals if (t := time.get_interval_time(iv)) is not None and t <= split_time]
|
||||||
|
side_b = [iv for iv in day_intervals if (t := time.get_interval_time(iv)) is not None and t > split_time]
|
||||||
|
|
||||||
|
_LOGGER.debug(
|
||||||
|
"%sSegment forcing %s (%s): split at %s (%d+%d intervals)",
|
||||||
|
INDENT_L1,
|
||||||
|
day_date,
|
||||||
|
target_pattern,
|
||||||
|
split_time.strftime("%H:%M"),
|
||||||
|
len(side_a),
|
||||||
|
len(side_b),
|
||||||
|
)
|
||||||
|
|
||||||
|
for side_name, side_intervals in (("A", side_a), ("B", side_b)):
|
||||||
|
side_times = {time.get_interval_time(iv) for iv in side_intervals}
|
||||||
|
count_in_side = sum(1 for p in merged_periods if _period_belongs_to_side(p, side_times, time))
|
||||||
|
|
||||||
|
_LOGGER.debug(
|
||||||
|
"%sSide %s: %d existing periods (need %d)",
|
||||||
|
INDENT_L2,
|
||||||
|
side_name,
|
||||||
|
count_in_side,
|
||||||
|
segment_min_periods,
|
||||||
|
)
|
||||||
|
|
||||||
|
if count_in_side >= segment_min_periods:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Run period detection restricted to this segment side via time_range.
|
||||||
|
# The full all_prices_smoothed (including other days) is passed so that
|
||||||
|
# reference price context remains day-wide; time_range restricts which
|
||||||
|
# intervals are EVALUATED as period candidates to this side only.
|
||||||
|
sorted_side = sorted(side_intervals, key=time.get_interval_time) # type: ignore[arg-type]
|
||||||
|
side_start = time.get_interval_time(sorted_side[0])
|
||||||
|
# end = one interval duration past the last interval's start
|
||||||
|
side_end = time.get_interval_time(sorted_side[-1])
|
||||||
|
if side_start is None or side_end is None:
|
||||||
|
continue
|
||||||
|
side_end = side_end + time.get_interval_duration()
|
||||||
|
new_raw = build_periods(
|
||||||
|
all_prices_smoothed,
|
||||||
|
price_context,
|
||||||
|
reverse_sort=reverse_sort,
|
||||||
|
level_filter=config.level_filter,
|
||||||
|
gap_count=config.gap_count,
|
||||||
|
time=time,
|
||||||
|
time_range=(side_start, side_end),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add non-duplicate periods; flag them with segment_forced=True
|
||||||
|
added = 0
|
||||||
|
for new_period in new_raw:
|
||||||
|
new_times = {time.get_interval_time(iv) for iv in new_period if time.get_interval_time(iv) is not None}
|
||||||
|
is_dup = any(
|
||||||
|
bool(
|
||||||
|
new_times
|
||||||
|
& {time.get_interval_time(iv) for iv in existing if time.get_interval_time(iv) is not None}
|
||||||
|
)
|
||||||
|
for existing in merged_periods
|
||||||
|
)
|
||||||
|
if not is_dup:
|
||||||
|
merged_periods.append([{**iv, "segment_forced": True} for iv in new_period])
|
||||||
|
added += 1
|
||||||
|
|
||||||
|
_LOGGER.debug(
|
||||||
|
"%sSide %s: added %d forced periods (%d candidates from restricted run)",
|
||||||
|
INDENT_L2,
|
||||||
|
side_name,
|
||||||
|
added,
|
||||||
|
len(new_raw),
|
||||||
|
)
|
||||||
|
|
||||||
|
return merged_periods
|
||||||
|
|
|
||||||
|
|
@ -62,6 +62,7 @@ def build_periods( # noqa: PLR0913, PLR0915, PLR0912 - Complex period building
|
||||||
level_filter: str | None = None,
|
level_filter: str | None = None,
|
||||||
gap_count: int = 0,
|
gap_count: int = 0,
|
||||||
time: TibberPricesTimeService,
|
time: TibberPricesTimeService,
|
||||||
|
time_range: tuple[datetime, datetime] | None = None,
|
||||||
) -> list[list[dict]]:
|
) -> list[list[dict]]:
|
||||||
"""
|
"""
|
||||||
Build periods, allowing periods to cross midnight (day boundary).
|
Build periods, allowing periods to cross midnight (day boundary).
|
||||||
|
|
@ -78,6 +79,10 @@ def build_periods( # noqa: PLR0913, PLR0915, PLR0912 - Complex period building
|
||||||
level_filter: Level filter string ("cheap", "expensive", "any", None)
|
level_filter: Level filter string ("cheap", "expensive", "any", None)
|
||||||
gap_count: Number of allowed consecutive intervals deviating by exactly 1 level step
|
gap_count: Number of allowed consecutive intervals deviating by exactly 1 level step
|
||||||
time: TibberPricesTimeService instance (required)
|
time: TibberPricesTimeService instance (required)
|
||||||
|
time_range: Optional (start_inclusive, end_exclusive) window. When set, only intervals
|
||||||
|
within [start, end) are considered as period candidates. Reference prices
|
||||||
|
(from price_context) remain day-wide and are unaffected by this filter.
|
||||||
|
Used by Phase 4 segment forcing to restrict detection to one segment side.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
ref_prices = price_context["ref_prices"]
|
ref_prices = price_context["ref_prices"]
|
||||||
|
|
@ -132,6 +137,11 @@ def build_periods( # noqa: PLR0913, PLR0915, PLR0912 - Complex period building
|
||||||
starts_at = time.get_interval_time(price_data)
|
starts_at = time.get_interval_time(price_data)
|
||||||
if starts_at is None:
|
if starts_at is None:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
# Filter by time range if specified (Phase 4 segment forcing)
|
||||||
|
if time_range is not None and not (time_range[0] <= starts_at < time_range[1]):
|
||||||
|
continue
|
||||||
|
|
||||||
date_key = starts_at.date()
|
date_key = starts_at.date()
|
||||||
|
|
||||||
# Use smoothed price for criteria checks (flex/distance)
|
# Use smoothed price for criteria checks (flex/distance)
|
||||||
|
|
|
||||||
|
|
@ -56,7 +56,7 @@ def recalculate_period_metadata(periods: list[dict], *, time: TibberPricesTimeSe
|
||||||
"""
|
"""
|
||||||
Recalculate period metadata after merging periods.
|
Recalculate period metadata after merging periods.
|
||||||
|
|
||||||
Updates period_position, periods_total, and periods_remaining for all periods
|
Updates period_position, period_count_total, and periods_remaining for all periods
|
||||||
based on chronological order.
|
based on chronological order.
|
||||||
|
|
||||||
This must be called after resolve_period_overlaps() to ensure metadata
|
This must be called after resolve_period_overlaps() to ensure metadata
|
||||||
|
|
@ -78,7 +78,7 @@ def recalculate_period_metadata(periods: list[dict], *, time: TibberPricesTimeSe
|
||||||
|
|
||||||
for position, period in enumerate(periods, 1):
|
for position, period in enumerate(periods, 1):
|
||||||
period["period_position"] = position
|
period["period_position"] = position
|
||||||
period["periods_total"] = total_periods
|
period["period_count_total"] = total_periods
|
||||||
period["periods_remaining"] = total_periods - position
|
period["periods_remaining"] = total_periods - position
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,8 @@ from custom_components.tibber_prices.utils.price import (
|
||||||
calculate_volatility_level,
|
calculate_volatility_level,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
from .types import LOW_PRICE_QUALITY_BYPASS_THRESHOLD, PERIOD_MAX_CV
|
||||||
|
|
||||||
|
|
||||||
def calculate_period_price_diff(
|
def calculate_period_price_diff(
|
||||||
price_mean: float,
|
price_mean: float,
|
||||||
|
|
@ -176,7 +178,7 @@ 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,
|
||||||
"periods_total": period_data.total_periods,
|
"period_count_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,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -220,18 +222,57 @@ def build_period_summary_dict(
|
||||||
return summary
|
return summary
|
||||||
|
|
||||||
|
|
||||||
def _add_interval_flag_counts(summary: dict, period: list[dict]) -> None:
|
def _strip_geo_from_edges(period: list[dict]) -> list[dict]:
|
||||||
"""Add optional interval flag counts to period summary."""
|
"""
|
||||||
|
Remove geo-bonus intervals from leading and trailing edges of a period.
|
||||||
|
|
||||||
|
Used by Phase 3 CV gate: when a period with geometric extension fails the CV quality
|
||||||
|
gate, the edge intervals that were included only via geo-bonus flex are stripped to
|
||||||
|
restore the period's unextended (tighter) boundaries.
|
||||||
|
|
||||||
|
Geo-bonus intervals in the MIDDLE of a period are preserved (they represent
|
||||||
|
intervals genuinely inside the valley/peak zone, not boundary extensions).
|
||||||
|
|
||||||
|
Returns an empty list only when all intervals are geo-bonus (degenerate case).
|
||||||
|
"""
|
||||||
|
start = 0
|
||||||
|
end = len(period)
|
||||||
|
while start < end and period[start].get("geometric_bonus_applied", False):
|
||||||
|
start += 1
|
||||||
|
while end > start and period[end - 1].get("geometric_bonus_applied", False):
|
||||||
|
end -= 1
|
||||||
|
return period[start:end]
|
||||||
|
|
||||||
|
|
||||||
|
def _add_interval_flag_counts(summary: dict, period: list[dict], *, geo_extension_status: str | None = None) -> None:
|
||||||
|
"""
|
||||||
|
Add optional interval flag counts to period summary.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
summary: Period summary dict to augment in-place.
|
||||||
|
period: Raw interval list (may already be stripped of geo-bonus edges).
|
||||||
|
geo_extension_status: "active" if geometric extension passed the CV gate,
|
||||||
|
"attempted" if it was tried but CV gate failed and period was reverted.
|
||||||
|
|
||||||
|
"""
|
||||||
if (count := sum(1 for i in period if i.get("smoothing_was_impactful", False))) > 0:
|
if (count := sum(1 for i in period if i.get("smoothing_was_impactful", False))) > 0:
|
||||||
summary["period_interval_smoothed_count"] = count
|
summary["period_interval_smoothed_count"] = count
|
||||||
if (count := sum(1 for i in period if i.get("is_level_gap", False))) > 0:
|
if (count := sum(1 for i in period if i.get("is_level_gap", False))) > 0:
|
||||||
summary["period_interval_level_gap_count"] = count
|
summary["period_interval_level_gap_count"] = count
|
||||||
if (count := sum(1 for i in period if i.get("geometric_bonus_applied", False))) > 0:
|
# Geometric extension: distinguish "active" (CV passed) from "attempted" (CV failed → reverted)
|
||||||
|
if geo_extension_status == "active":
|
||||||
|
count = sum(1 for i in period if i.get("geometric_bonus_applied", False))
|
||||||
summary["geometric_extension_active"] = True
|
summary["geometric_extension_active"] = True
|
||||||
summary["geometric_extension_intervals"] = count
|
summary["geometric_extension_intervals"] = count
|
||||||
|
elif geo_extension_status == "attempted":
|
||||||
|
# CV gate failed: geo extension was tried but period was reverted to base boundaries.
|
||||||
|
# The summary uses unextended (stripped) boundaries; this flag marks the attempt.
|
||||||
|
summary["geometric_extension_attempted"] = True
|
||||||
|
if any(i.get("segment_forced", False) for i in period):
|
||||||
|
summary["segment_forced"] = True
|
||||||
|
|
||||||
|
|
||||||
def extract_period_summaries(
|
def extract_period_summaries( # noqa: PLR0912, PLR0915 - CV pre-check for geo-extension adds necessary branches/statements
|
||||||
periods: list[list[dict]],
|
periods: list[list[dict]],
|
||||||
all_prices: list[dict],
|
all_prices: list[dict],
|
||||||
price_context: dict[str, Any],
|
price_context: dict[str, Any],
|
||||||
|
|
@ -280,6 +321,34 @@ def extract_period_summaries(
|
||||||
if not period:
|
if not period:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
# Phase 3: Geometric extension CV gate check
|
||||||
|
# If this period contains geo-bonus intervals, pre-check whether the full period
|
||||||
|
# passes the CV quality gate. If it fails, revert to base boundaries by stripping
|
||||||
|
# geo-bonus intervals from the edges and mark with geometric_extension_attempted.
|
||||||
|
geo_extension_status: str | None = None
|
||||||
|
if any(iv.get("geometric_bonus_applied", False) for iv in period):
|
||||||
|
full_prices: list[float] = []
|
||||||
|
for iv in period:
|
||||||
|
start_iv = iv.get("interval_start")
|
||||||
|
if start_iv:
|
||||||
|
p = price_lookup.get(start_iv.isoformat())
|
||||||
|
if p:
|
||||||
|
full_prices.append(float(p["total"]))
|
||||||
|
if full_prices:
|
||||||
|
full_cv = calculate_coefficient_of_variation(full_prices)
|
||||||
|
cv_fails = (
|
||||||
|
full_cv is not None
|
||||||
|
and sum(full_prices) / len(full_prices) >= LOW_PRICE_QUALITY_BYPASS_THRESHOLD
|
||||||
|
and full_cv > PERIOD_MAX_CV
|
||||||
|
)
|
||||||
|
if cv_fails:
|
||||||
|
base_period = _strip_geo_from_edges(period)
|
||||||
|
if base_period:
|
||||||
|
period = base_period # noqa: PLW2901 - intentional period replacement
|
||||||
|
geo_extension_status = "attempted"
|
||||||
|
else:
|
||||||
|
geo_extension_status = "active"
|
||||||
|
|
||||||
first_interval = period[0]
|
first_interval = period[0]
|
||||||
last_interval = period[-1]
|
last_interval = period[-1]
|
||||||
|
|
||||||
|
|
@ -369,7 +438,7 @@ def extract_period_summaries(
|
||||||
)
|
)
|
||||||
|
|
||||||
# Add optional interval flag counts (smoothing, level gaps, geometric extension)
|
# Add optional interval flag counts (smoothing, level gaps, geometric extension)
|
||||||
_add_interval_flag_counts(summary, period)
|
_add_interval_flag_counts(summary, period, geo_extension_status=geo_extension_status)
|
||||||
|
|
||||||
summaries.append(summary)
|
summaries.append(summary)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from datetime import date
|
from datetime import date, datetime
|
||||||
|
|
||||||
from custom_components.tibber_prices.coordinator.time_service import TibberPricesTimeService
|
from custom_components.tibber_prices.coordinator.time_service import TibberPricesTimeService
|
||||||
|
|
||||||
|
|
@ -22,6 +22,7 @@ from .types import (
|
||||||
INDENT_L0,
|
INDENT_L0,
|
||||||
INDENT_L1,
|
INDENT_L1,
|
||||||
INDENT_L2,
|
INDENT_L2,
|
||||||
|
LOW_PRICE_QUALITY_BYPASS_THRESHOLD,
|
||||||
PERIOD_MAX_CV,
|
PERIOD_MAX_CV,
|
||||||
TibberPricesPeriodConfig,
|
TibberPricesPeriodConfig,
|
||||||
)
|
)
|
||||||
|
|
@ -41,12 +42,6 @@ FLEX_HIGH_THRESHOLD_RELAXATION = 0.30 # 30% - WARNING: base flex too high for r
|
||||||
MIN_DURATION_FALLBACK_MINIMUM = 30 # Minimum period length to try (30 min = 2 intervals)
|
MIN_DURATION_FALLBACK_MINIMUM = 30 # Minimum period length to try (30 min = 2 intervals)
|
||||||
MIN_DURATION_FALLBACK_STEP = 15 # Reduce by 15 min (1 interval) each step
|
MIN_DURATION_FALLBACK_STEP = 15 # Reduce by 15 min (1 interval) each step
|
||||||
|
|
||||||
# Low absolute price threshold for quality gate bypass (in major currency unit, e.g. EUR/NOK)
|
|
||||||
# When the MEAN price of a period is below this level, the CV quality gate is bypassed.
|
|
||||||
# Relative CV is unreliable at very low absolute prices: a range of 1-4 ct shows CV≈50%
|
|
||||||
# but is practically homogeneous from a cost perspective.
|
|
||||||
# Value: LOW_PRICE_AVG_THRESHOLD (subunit) / 100 = 10 ct / 100 = 0.10 EUR/NOK
|
|
||||||
LOW_PRICE_QUALITY_BYPASS_THRESHOLD = 0.10 # EUR/NOK major unit (= 10 ct/øre)
|
|
||||||
|
|
||||||
# Span-to-ref ratio threshold for suppressing flex warnings on V-shape days.
|
# Span-to-ref ratio threshold for suppressing flex warnings on V-shape days.
|
||||||
# When span / ref_price < this on ANY available day, the warning is shown.
|
# When span / ref_price < this on ANY available day, the warning is shown.
|
||||||
|
|
@ -527,6 +522,7 @@ def calculate_periods_with_relaxation( # noqa: PLR0912, PLR0913, PLR0915 - Per-
|
||||||
time: TibberPricesTimeService,
|
time: TibberPricesTimeService,
|
||||||
config_entry: Any, # ConfigEntry type
|
config_entry: Any, # ConfigEntry type
|
||||||
day_patterns_by_date: dict | None = None,
|
day_patterns_by_date: dict | None = None,
|
||||||
|
time_range: tuple[datetime, datetime] | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Calculate periods with optional per-day filter relaxation.
|
Calculate periods with optional per-day filter relaxation.
|
||||||
|
|
@ -555,6 +551,9 @@ def calculate_periods_with_relaxation( # noqa: PLR0912, PLR0913, PLR0915 - Per-
|
||||||
config_entry: Config entry to get display unit configuration.
|
config_entry: Config entry to get display unit configuration.
|
||||||
day_patterns_by_date: Optional dict mapping date → day pattern dict. Used for
|
day_patterns_by_date: Optional dict mapping date → day pattern dict. Used for
|
||||||
geometric flex bonus in period detection. Passed through to calculate_periods().
|
geometric flex bonus in period detection. Passed through to calculate_periods().
|
||||||
|
time_range: Optional (start_inclusive, end_exclusive) datetime window. When set,
|
||||||
|
only intervals within [start, end) are considered as period candidates.
|
||||||
|
Passed through to calculate_periods(). Used by Phase 4 segment forcing.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dict with same format as calculate_periods() output:
|
Dict with same format as calculate_periods() output:
|
||||||
|
|
@ -640,7 +639,6 @@ def calculate_periods_with_relaxation( # noqa: PLR0912, PLR0913, PLR0915 - Per-
|
||||||
"relaxation_active": False,
|
"relaxation_active": False,
|
||||||
"relaxation_attempted": False,
|
"relaxation_attempted": False,
|
||||||
"min_periods_requested": min_periods if enable_relaxation else 0,
|
"min_periods_requested": min_periods if enable_relaxation else 0,
|
||||||
"periods_found": 0,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"reference_data": {},
|
"reference_data": {},
|
||||||
|
|
@ -712,7 +710,9 @@ def calculate_periods_with_relaxation( # noqa: PLR0912, PLR0913, PLR0915 - Per-
|
||||||
# === BASELINE CALCULATION (process ALL prices together, including yesterday) ===
|
# === BASELINE CALCULATION (process ALL prices together, including yesterday) ===
|
||||||
# Periods that ended before yesterday will be filtered out later by filter_periods_by_end_date()
|
# Periods that ended before yesterday will be filtered out later by filter_periods_by_end_date()
|
||||||
# This keeps yesterday/today/tomorrow periods in the cache
|
# This keeps yesterday/today/tomorrow periods in the cache
|
||||||
baseline_result = calculate_periods(all_prices, config=config, time=time, day_patterns_by_date=day_patterns_by_date)
|
baseline_result = calculate_periods(
|
||||||
|
all_prices, config=config, time=time, day_patterns_by_date=day_patterns_by_date, time_range=time_range
|
||||||
|
)
|
||||||
all_periods = baseline_result["periods"]
|
all_periods = baseline_result["periods"]
|
||||||
|
|
||||||
# Count periods per day for min_periods check
|
# Count periods per day for min_periods check
|
||||||
|
|
@ -839,8 +839,6 @@ def calculate_periods_with_relaxation( # noqa: PLR0912, PLR0913, PLR0915 - Per-
|
||||||
final_result = baseline_result.copy()
|
final_result = baseline_result.copy()
|
||||||
final_result["periods"] = all_periods
|
final_result["periods"] = all_periods
|
||||||
|
|
||||||
total_periods = len(all_periods)
|
|
||||||
|
|
||||||
# Add relaxation info to metadata
|
# Add relaxation info to metadata
|
||||||
if "metadata" not in final_result:
|
if "metadata" not in final_result:
|
||||||
final_result["metadata"] = {}
|
final_result["metadata"] = {}
|
||||||
|
|
@ -848,7 +846,6 @@ def calculate_periods_with_relaxation( # noqa: PLR0912, PLR0913, PLR0915 - Per-
|
||||||
"relaxation_active": relaxation_was_needed,
|
"relaxation_active": relaxation_was_needed,
|
||||||
"relaxation_attempted": relaxation_was_needed,
|
"relaxation_attempted": relaxation_was_needed,
|
||||||
"min_periods_requested": min_periods,
|
"min_periods_requested": min_periods,
|
||||||
"periods_found": total_periods,
|
|
||||||
"phases_used": list(set(all_phases_used)), # Unique phases used across all days
|
"phases_used": list(set(all_phases_used)), # Unique phases used across all days
|
||||||
"days_processed": total_days,
|
"days_processed": total_days,
|
||||||
"days_meeting_requirement": days_meeting_requirement,
|
"days_meeting_requirement": days_meeting_requirement,
|
||||||
|
|
@ -955,7 +952,10 @@ def relax_all_prices( # noqa: PLR0913 - Comprehensive filter relaxation require
|
||||||
|
|
||||||
# Process ALL prices together (allows midnight crossing)
|
# Process ALL prices together (allows midnight crossing)
|
||||||
result = calculate_periods(
|
result = calculate_periods(
|
||||||
all_prices, config=relaxed_config, time=time, day_patterns_by_date=day_patterns_by_date
|
all_prices,
|
||||||
|
config=relaxed_config,
|
||||||
|
time=time,
|
||||||
|
day_patterns_by_date=day_patterns_by_date,
|
||||||
)
|
)
|
||||||
new_periods = result["periods"]
|
new_periods = result["periods"]
|
||||||
|
|
||||||
|
|
@ -1019,5 +1019,4 @@ def relax_all_prices( # noqa: PLR0913 - Comprehensive filter relaxation require
|
||||||
|
|
||||||
return final_result, {
|
return final_result, {
|
||||||
"phases_used": phases_used,
|
"phases_used": phases_used,
|
||||||
"periods_found": len(existing_periods),
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,13 @@ from custom_components.tibber_prices.const import (
|
||||||
# Period with prices 0.5-1.0 kr has ~30% CV which would be rejected
|
# Period with prices 0.5-1.0 kr has ~30% CV which would be rejected
|
||||||
PERIOD_MAX_CV = 25.0 # 25% max coefficient of variation within a period
|
PERIOD_MAX_CV = 25.0 # 25% max coefficient of variation within a period
|
||||||
|
|
||||||
|
# Low absolute price threshold for quality gate bypass (in major currency unit, e.g. EUR/NOK)
|
||||||
|
# When the MEAN price of a period is below this level, the CV quality gate is bypassed.
|
||||||
|
# Relative CV is unreliable at very low absolute prices: a range of 1-4 ct shows CV≈50%
|
||||||
|
# but is practically homogeneous from a cost perspective.
|
||||||
|
# Value: 10 ct / 100 = 0.10 EUR/NOK
|
||||||
|
LOW_PRICE_QUALITY_BYPASS_THRESHOLD = 0.10 # EUR/NOK major unit (= 10 ct/øre)
|
||||||
|
|
||||||
# Cross-Day Extension: Time window constants
|
# Cross-Day Extension: Time window constants
|
||||||
# When a period ends late in the day and tomorrow data is available,
|
# When a period ends late in the day and tomorrow data is available,
|
||||||
# we can extend it past midnight if prices remain favorable
|
# we can extend it past midnight if prices remain favorable
|
||||||
|
|
@ -59,6 +66,8 @@ class TibberPricesPeriodConfig(NamedTuple):
|
||||||
extend_to_extreme: bool = False # Extend periods into adjacent VERY_CHEAP/VERY_EXPENSIVE intervals
|
extend_to_extreme: bool = False # Extend periods into adjacent VERY_CHEAP/VERY_EXPENSIVE intervals
|
||||||
max_extension_intervals: int = 0 # Max intervals this extension may add per side (0 = disabled)
|
max_extension_intervals: int = 0 # Max intervals this extension may add per side (0 = disabled)
|
||||||
geometric_extra_flex: float = 0.0 # Extra flex (decimal) for intervals inside the valley/peak zone (0.0 = disabled)
|
geometric_extra_flex: float = 0.0 # Extra flex (decimal) for intervals inside the valley/peak zone (0.0 = disabled)
|
||||||
|
segment_forcing: bool = False # Force at least segment_min_periods in each W/M-shape segment
|
||||||
|
segment_min_periods: int = 1 # Minimum periods required per segment when segment_forcing is True
|
||||||
|
|
||||||
|
|
||||||
class TibberPricesPeriodData(NamedTuple):
|
class TibberPricesPeriodData(NamedTuple):
|
||||||
|
|
|
||||||
|
|
@ -280,6 +280,40 @@ class TibberPricesPeriodCalculator:
|
||||||
)
|
)
|
||||||
config["geometric_extra_flex"] = geometric_flex_pct / 100
|
config["geometric_extra_flex"] = geometric_flex_pct / 100
|
||||||
|
|
||||||
|
# Segment forcing (force at least segment_min_periods per W/M-shape segment)
|
||||||
|
if reverse_sort:
|
||||||
|
segment_forcing = bool(
|
||||||
|
self._get_option(
|
||||||
|
_const.CONF_PEAK_PRICE_SEGMENT_FORCING,
|
||||||
|
"extension_settings",
|
||||||
|
_const.DEFAULT_PEAK_PRICE_SEGMENT_FORCING,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
segment_min_periods = int(
|
||||||
|
self._get_option(
|
||||||
|
_const.CONF_PEAK_PRICE_SEGMENT_MIN_PERIODS,
|
||||||
|
"extension_settings",
|
||||||
|
_const.DEFAULT_PEAK_PRICE_SEGMENT_MIN_PERIODS,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
segment_forcing = bool(
|
||||||
|
self._get_option(
|
||||||
|
_const.CONF_BEST_PRICE_SEGMENT_FORCING,
|
||||||
|
"extension_settings",
|
||||||
|
_const.DEFAULT_BEST_PRICE_SEGMENT_FORCING,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
segment_min_periods = int(
|
||||||
|
self._get_option(
|
||||||
|
_const.CONF_BEST_PRICE_SEGMENT_MIN_PERIODS,
|
||||||
|
"extension_settings",
|
||||||
|
_const.DEFAULT_BEST_PRICE_SEGMENT_MIN_PERIODS,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
config["segment_forcing"] = segment_forcing
|
||||||
|
config["segment_min_periods"] = segment_min_periods
|
||||||
|
|
||||||
# Cache the result
|
# Cache the result
|
||||||
self._config_cache[cache_key] = config
|
self._config_cache[cache_key] = config
|
||||||
self._config_cache_valid = True
|
self._config_cache_valid = True
|
||||||
|
|
@ -774,6 +808,8 @@ class TibberPricesPeriodCalculator:
|
||||||
extend_to_extreme=best_config["extend_to_extreme"],
|
extend_to_extreme=best_config["extend_to_extreme"],
|
||||||
max_extension_intervals=best_config["max_extension_intervals"],
|
max_extension_intervals=best_config["max_extension_intervals"],
|
||||||
geometric_extra_flex=best_config["geometric_extra_flex"],
|
geometric_extra_flex=best_config["geometric_extra_flex"],
|
||||||
|
segment_forcing=best_config["segment_forcing"],
|
||||||
|
segment_min_periods=best_config["segment_min_periods"],
|
||||||
)
|
)
|
||||||
best_periods = calculate_periods_with_relaxation(
|
best_periods = calculate_periods_with_relaxation(
|
||||||
all_prices,
|
all_prices,
|
||||||
|
|
@ -859,6 +895,8 @@ class TibberPricesPeriodCalculator:
|
||||||
extend_to_extreme=peak_config["extend_to_extreme"],
|
extend_to_extreme=peak_config["extend_to_extreme"],
|
||||||
max_extension_intervals=peak_config["max_extension_intervals"],
|
max_extension_intervals=peak_config["max_extension_intervals"],
|
||||||
geometric_extra_flex=peak_config["geometric_extra_flex"],
|
geometric_extra_flex=peak_config["geometric_extra_flex"],
|
||||||
|
segment_forcing=peak_config["segment_forcing"],
|
||||||
|
segment_min_periods=peak_config["segment_min_periods"],
|
||||||
)
|
)
|
||||||
peak_periods = calculate_periods_with_relaxation(
|
peak_periods = calculate_periods_with_relaxation(
|
||||||
all_prices,
|
all_prices,
|
||||||
|
|
|
||||||
|
|
@ -172,7 +172,7 @@ class TibberPricesSensor(TibberPricesEntity, RestoreSensor):
|
||||||
"rating_difference_%",
|
"rating_difference_%",
|
||||||
"period_price_diff_from_daily_min",
|
"period_price_diff_from_daily_min",
|
||||||
"period_price_diff_from_daily_min_%",
|
"period_price_diff_from_daily_min_%",
|
||||||
"periods_total",
|
"period_count_total",
|
||||||
"periods_remaining",
|
"periods_remaining",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -245,12 +245,16 @@
|
||||||
"data": {
|
"data": {
|
||||||
"best_price_extend_to_very_cheap": "Auf sehr günstige Intervalle erweitern",
|
"best_price_extend_to_very_cheap": "Auf sehr günstige Intervalle erweitern",
|
||||||
"best_price_max_extension_intervals": "Maximale Erweiterungsintervalle",
|
"best_price_max_extension_intervals": "Maximale Erweiterungsintervalle",
|
||||||
"best_price_geometric_flex": "Geometrischer Flex-Bonus"
|
"best_price_geometric_flex": "Geometrischer Flex-Bonus",
|
||||||
|
"best_price_segment_forcing": "W-Form-Segment-Erzwingung",
|
||||||
|
"best_price_segment_min_periods": "Perioden pro Segment"
|
||||||
},
|
},
|
||||||
"data_description": {
|
"data_description": {
|
||||||
"best_price_extend_to_very_cheap": "Wenn aktiviert, erweitern sich erkannte Bestpreisperioden nach außen, um angrenzende Intervalle mit dem Preisniveau 'Sehr günstig' aufzunehmen. So werden extrem günstige Intervalle an den Rändern erkannter Perioden besser erfasst.",
|
"best_price_extend_to_very_cheap": "Wenn aktiviert, erweitern sich erkannte Bestpreisperioden nach außen, um angrenzende Intervalle mit dem Preisniveau 'Sehr günstig' aufzunehmen. So werden extrem günstige Intervalle an den Rändern erkannter Perioden besser erfasst.",
|
||||||
"best_price_max_extension_intervals": "Maximale Anzahl zusätzlicher Intervalle pro Seite (linker und rechter Rand). Jedes Intervall dauert 15 Minuten. Beispiel: 4 Intervalle = bis zu 1 Stunde Erweiterung pro Rand. Standard: 4",
|
"best_price_max_extension_intervals": "Maximale Anzahl zusätzlicher Intervalle pro Seite (linker und rechter Rand). Jedes Intervall dauert 15 Minuten. Beispiel: 4 Intervalle = bis zu 1 Stunde Erweiterung pro Rand. Standard: 4",
|
||||||
"best_price_geometric_flex": "Zusätzlicher Flex-Prozentsatz für Intervalle, die in ein erkanntes Preistal (V-Form) fallen. Wenn für den Tag ein Tal-Muster erkannt wird, erhalten Intervalle innerhalb der Talzone diese zusätzliche Toleranz, damit der Periodendetektor sie eher einschließt. 0 = deaktiviert. Standard: 0"
|
"best_price_geometric_flex": "Zusätzlicher Flex-Prozentsatz für Intervalle, die in ein erkanntes Preistal (V-Form) fallen. Wenn für den Tag ein Tal-Muster erkannt wird, erhalten Intervalle innerhalb der Talzone diese zusätzliche Toleranz, damit der Periodendetektor sie eher einschließt. 0 = deaktiviert. Standard: 0",
|
||||||
|
"best_price_segment_forcing": "Wenn aktiviert, werden Tage mit W-förmigem Preiskurve (zwei Täler, getrennt durch einen zentralen Gipfel) an diesem Gipfel geteilt. Die Periodenerkennung läuft unabhängig für jede Talseite, um sicherzustellen, dass jedes Tal die erforderliche Anzahl von Perioden erhält.",
|
||||||
|
"best_price_segment_min_periods": "Mindestanzahl erforderlicher Bestpreisperioden pro Talseite bei aktivierter W-Form-Segment-Erzwingung. Jede Seite muss unabhängig mindestens diese Anzahl Perioden liefern. Standard: 1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -306,12 +310,16 @@
|
||||||
"data": {
|
"data": {
|
||||||
"peak_price_extend_to_very_expensive": "Auf sehr teure Intervalle erweitern",
|
"peak_price_extend_to_very_expensive": "Auf sehr teure Intervalle erweitern",
|
||||||
"peak_price_max_extension_intervals": "Maximale Erweiterungsintervalle",
|
"peak_price_max_extension_intervals": "Maximale Erweiterungsintervalle",
|
||||||
"peak_price_geometric_flex": "Geometrischer Flex-Bonus"
|
"peak_price_geometric_flex": "Geometrischer Flex-Bonus",
|
||||||
|
"peak_price_segment_forcing": "M-Form-Segment-Erzwingung",
|
||||||
|
"peak_price_segment_min_periods": "Perioden pro Segment"
|
||||||
},
|
},
|
||||||
"data_description": {
|
"data_description": {
|
||||||
"peak_price_extend_to_very_expensive": "Wenn aktiviert, erweitern sich erkannte Spitzenpreisperioden nach außen, um angrenzende Intervalle mit dem Preisniveau 'Sehr teuer' aufzunehmen. So werden extrem teure Intervalle an den Rändern erkannter Perioden besser erfasst.",
|
"peak_price_extend_to_very_expensive": "Wenn aktiviert, erweitern sich erkannte Spitzenpreisperioden nach außen, um angrenzende Intervalle mit dem Preisniveau 'Sehr teuer' aufzunehmen. So werden extrem teure Intervalle an den Rändern erkannter Perioden besser erfasst.",
|
||||||
"peak_price_max_extension_intervals": "Maximale Anzahl zusätzlicher Intervalle pro Seite (linker und rechter Rand). Jedes Intervall dauert 15 Minuten. Beispiel: 4 Intervalle = bis zu 1 Stunde Erweiterung pro Rand. Standard: 4",
|
"peak_price_max_extension_intervals": "Maximale Anzahl zusätzlicher Intervalle pro Seite (linker und rechter Rand). Jedes Intervall dauert 15 Minuten. Beispiel: 4 Intervalle = bis zu 1 Stunde Erweiterung pro Rand. Standard: 4",
|
||||||
"peak_price_geometric_flex": "Zusätzlicher Flex-Prozentsatz für Intervalle, die in einen erkannten Preisplateau (Λ-Form) fallen. Wenn für den Tag ein Gipfel-Muster erkannt wird, erhalten Intervalle innerhalb der Gipfelzone diese zusätzliche Toleranz, damit der Periodendetektor sie eher einschließt. 0 = deaktiviert. Standard: 0"
|
"peak_price_geometric_flex": "Zusätzlicher Flex-Prozentsatz für Intervalle, die in einen erkannten Preisplateau (Λ-Form) fallen. Wenn für den Tag ein Gipfel-Muster erkannt wird, erhalten Intervalle innerhalb der Gipfelzone diese zusätzliche Toleranz, damit der Periodendetektor sie eher einschließt. 0 = deaktiviert. Standard: 0",
|
||||||
|
"peak_price_segment_forcing": "Wenn aktiviert, werden Tage mit M-förmigem Preiskurve (zwei Gipfel, getrennt durch ein zentrales Tal) an diesem Tal geteilt. Die Periodenerkennung läuft unabhängig für jede Gipfelseite, um sicherzustellen, dass jeder Gipfel die erforderliche Anzahl von Perioden erhält.",
|
||||||
|
"peak_price_segment_min_periods": "Mindestanzahl erforderlicher Spitzenpreisperioden pro Gipfelseite bei aktivierter M-Form-Segment-Erzwingung. Jede Seite muss unabhängig mindestens diese Anzahl Perioden liefern. Standard: 1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -256,12 +256,16 @@
|
||||||
"data": {
|
"data": {
|
||||||
"best_price_extend_to_very_cheap": "Extend to Very Cheap Intervals",
|
"best_price_extend_to_very_cheap": "Extend to Very Cheap Intervals",
|
||||||
"best_price_max_extension_intervals": "Maximum Extension Intervals",
|
"best_price_max_extension_intervals": "Maximum Extension Intervals",
|
||||||
"best_price_geometric_flex": "Geometric Flex Bonus"
|
"best_price_geometric_flex": "Geometric Flex Bonus",
|
||||||
|
"best_price_segment_forcing": "W-Shape Segment Forcing",
|
||||||
|
"best_price_segment_min_periods": "Periods per Segment"
|
||||||
},
|
},
|
||||||
"data_description": {
|
"data_description": {
|
||||||
"best_price_extend_to_very_cheap": "When enabled, detected best price periods expand outward to absorb adjacent intervals with a 'Very cheap' price level. This widens low-price windows to better capture extremely cheap intervals at the edges of detected periods.",
|
"best_price_extend_to_very_cheap": "When enabled, detected best price periods expand outward to absorb adjacent intervals with a 'Very cheap' price level. This widens low-price windows to better capture extremely cheap intervals at the edges of detected periods.",
|
||||||
"best_price_max_extension_intervals": "Maximum number of additional intervals to absorb per side (left and right edge). Each interval is 15 minutes. Example: 4 intervals = up to 1 hour extension per edge. Default: 4",
|
"best_price_max_extension_intervals": "Maximum number of additional intervals to absorb per side (left and right edge). Each interval is 15 minutes. Example: 4 intervals = up to 1 hour extension per edge. Default: 4",
|
||||||
"best_price_geometric_flex": "Extra flex percentage applied to intervals that fall inside a detected price valley (V-shape). When a valley pattern is detected for the day, intervals within the valley zone get this additional tolerance, making the period detector more likely to include them. 0 = disabled. Default: 0"
|
"best_price_geometric_flex": "Extra flex percentage applied to intervals that fall inside a detected price valley (V-shape). When a valley pattern is detected for the day, intervals within the valley zone get this additional tolerance, making the period detector more likely to include them. 0 = disabled. Default: 0",
|
||||||
|
"best_price_segment_forcing": "When enabled, days with a W-shaped price curve (two valleys separated by a central peak) split at the central peak. Period detection runs independently for each valley side, ensuring each valley has the required number of periods. This prevents both periods from clustering in the same valley. Requires the day pattern sensor to detect a 'double_valley' pattern.",
|
||||||
|
"best_price_segment_min_periods": "Minimum number of best price periods required per valley side when W-shape segment forcing is enabled. Each side must independently produce at least this many periods. Default: 1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -317,12 +321,16 @@
|
||||||
"data": {
|
"data": {
|
||||||
"peak_price_extend_to_very_expensive": "Extend to Very Expensive Intervals",
|
"peak_price_extend_to_very_expensive": "Extend to Very Expensive Intervals",
|
||||||
"peak_price_max_extension_intervals": "Maximum Extension Intervals",
|
"peak_price_max_extension_intervals": "Maximum Extension Intervals",
|
||||||
"peak_price_geometric_flex": "Geometric Flex Bonus"
|
"peak_price_geometric_flex": "Geometric Flex Bonus",
|
||||||
|
"peak_price_segment_forcing": "M-Shape Segment Forcing",
|
||||||
|
"peak_price_segment_min_periods": "Periods per Segment"
|
||||||
},
|
},
|
||||||
"data_description": {
|
"data_description": {
|
||||||
"peak_price_extend_to_very_expensive": "When enabled, detected peak price periods expand outward to absorb adjacent intervals with a 'Very expensive' price level. This widens high-price windows to better capture extremely expensive intervals at the edges of detected periods.",
|
"peak_price_extend_to_very_expensive": "When enabled, detected peak price periods expand outward to absorb adjacent intervals with a 'Very expensive' price level. This widens high-price windows to better capture extremely expensive intervals at the edges of detected periods.",
|
||||||
"peak_price_max_extension_intervals": "Maximum number of additional intervals to absorb per side (left and right edge). Each interval is 15 minutes. Example: 4 intervals = up to 1 hour extension per edge. Default: 4",
|
"peak_price_max_extension_intervals": "Maximum number of additional intervals to absorb per side (left and right edge). Each interval is 15 minutes. Example: 4 intervals = up to 1 hour extension per edge. Default: 4",
|
||||||
"peak_price_geometric_flex": "Extra flex percentage applied to intervals that fall inside a detected price peak (Λ-shape). When a peak pattern is detected for the day, intervals within the peak zone get this additional tolerance, making the period detector more likely to include them. 0 = disabled. Default: 0"
|
"peak_price_geometric_flex": "Extra flex percentage applied to intervals that fall inside a detected price peak (Λ-shape). When a peak pattern is detected for the day, intervals within the peak zone get this additional tolerance, making the period detector more likely to include them. 0 = disabled. Default: 0",
|
||||||
|
"peak_price_segment_forcing": "When enabled, days with an M-shaped price curve (two peaks separated by a central valley) split at the central valley. Period detection runs independently for each peak side, ensuring each peak has the required number of periods. This prevents both periods from clustering in the same peak. Requires the day pattern sensor to detect a 'double_peak' pattern.",
|
||||||
|
"peak_price_segment_min_periods": "Minimum number of peak price periods required per peak side when M-shape segment forcing is enabled. Each side must independently produce at least this many periods. Default: 1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -245,12 +245,16 @@
|
||||||
"data": {
|
"data": {
|
||||||
"best_price_extend_to_very_cheap": "Utvid til svært billige intervaller",
|
"best_price_extend_to_very_cheap": "Utvid til svært billige intervaller",
|
||||||
"best_price_max_extension_intervals": "Maksimale utvidelsesintervaller",
|
"best_price_max_extension_intervals": "Maksimale utvidelsesintervaller",
|
||||||
"best_price_geometric_flex": "Geometrisk fleksbonus"
|
"best_price_geometric_flex": "Geometrisk fleksbonus",
|
||||||
|
"best_price_segment_forcing": "W-form segment-tvinging",
|
||||||
|
"best_price_segment_min_periods": "Perioder per segment"
|
||||||
},
|
},
|
||||||
"data_description": {
|
"data_description": {
|
||||||
"best_price_extend_to_very_cheap": "Når aktivert, utvider oppdagede bestprisperioder seg utover for å inkludere tilstøtende intervaller med prisnivået 'Svært billig'. Dette fanger opp ekstremt billige intervaller ved kantene av oppdagede perioder.",
|
"best_price_extend_to_very_cheap": "Når aktivert, utvider oppdagede bestprisperioder seg utover for å inkludere tilstøtende intervaller med prisnivået 'Svært billig'. Dette fanger opp ekstremt billige intervaller ved kantene av oppdagede perioder.",
|
||||||
"best_price_max_extension_intervals": "Maksimalt antall ekstra intervaller per side (venstre og høyre kant). Hvert intervall er 15 minutter. Eksempel: 4 intervaller = opptil 1 times utvidelse per kant. Standard: 4",
|
"best_price_max_extension_intervals": "Maksimalt antall ekstra intervaller per side (venstre og høyre kant). Hvert intervall er 15 minutter. Eksempel: 4 intervaller = opptil 1 times utvidelse per kant. Standard: 4",
|
||||||
"best_price_geometric_flex": "Ekstra fleksprosent for intervaller som faller innenfor en oppdaget prisdal (V-form). Når et dal-mønster oppdages for dagen, får intervaller innen dalsonen denne ekstra toleransen, slik at periodevarslingssystemet er mer tilbøyelig til å inkludere dem. 0 = deaktivert. Standard: 0"
|
"best_price_geometric_flex": "Ekstra fleksprosent for intervaller som faller innenfor en oppdaget prisdal (V-form). Når et dal-mønster oppdages for dagen, får intervaller innen dalsonen denne ekstra toleransen, slik at periodevarslingssystemet er mer tilbøyelig til å inkludere dem. 0 = deaktivert. Standard: 0",
|
||||||
|
"best_price_segment_forcing": "Når aktivert deles dager med W-formet priskurve (to daler adskilt av en sentral topp) ved den sentrale toppen. Periodedetektor kjøres uavhengig for hver dalside for å sikre at hvert dal får det påkrevde antallet perioder.",
|
||||||
|
"best_price_segment_min_periods": "Minimum antall bestprisperioder per dalside når W-form segment-tvinging er aktivert. Hver side må uavhengig produsere minst dette antallet perioder. Standard: 1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -306,12 +310,16 @@
|
||||||
"data": {
|
"data": {
|
||||||
"peak_price_extend_to_very_expensive": "Utvid til svært dyre intervaller",
|
"peak_price_extend_to_very_expensive": "Utvid til svært dyre intervaller",
|
||||||
"peak_price_max_extension_intervals": "Maksimale utvidelsesintervaller",
|
"peak_price_max_extension_intervals": "Maksimale utvidelsesintervaller",
|
||||||
"peak_price_geometric_flex": "Geometrisk fleksbonus"
|
"peak_price_geometric_flex": "Geometrisk fleksbonus",
|
||||||
|
"peak_price_segment_forcing": "M-form segment-tvinging",
|
||||||
|
"peak_price_segment_min_periods": "Perioder per segment"
|
||||||
},
|
},
|
||||||
"data_description": {
|
"data_description": {
|
||||||
"peak_price_extend_to_very_expensive": "Når aktivert, utvider oppdagede topprisperioder seg utover for å inkludere tilstøtende intervaller med prisnivået 'Svært dyrt'. Dette fanger opp ekstremt dyre intervaller ved kantene av oppdagede perioder.",
|
"peak_price_extend_to_very_expensive": "Når aktivert, utvider oppdagede topprisperioder seg utover for å inkludere tilstøtende intervaller med prisnivået 'Svært dyrt'. Dette fanger opp ekstremt dyre intervaller ved kantene av oppdagede perioder.",
|
||||||
"peak_price_max_extension_intervals": "Maksimalt antall ekstra intervaller per side (venstre og høyre kant). Hvert intervall er 15 minutter. Eksempel: 4 intervaller = opptil 1 times utvidelse per kant. Standard: 4",
|
"peak_price_max_extension_intervals": "Maksimalt antall ekstra intervaller per side (venstre og høyre kant). Hvert intervall er 15 minutter. Eksempel: 4 intervaller = opptil 1 times utvidelse per kant. Standard: 4",
|
||||||
"peak_price_geometric_flex": "Ekstra fleksprosent for intervaller som faller innenfor en oppdaget pristopp (Λ-form). Når et topp-mønster oppdages for dagen, får intervaller innen toppsonene denne ekstra toleransen, slik at periodevarslingssystemet er mer tilbøyelig til å inkludere dem. 0 = deaktivert. Standard: 0"
|
"peak_price_geometric_flex": "Ekstra fleksprosent for intervaller som faller innenfor en oppdaget pristopp (Λ-form). Når et topp-mønster oppdages for dagen, får intervaller innen toppsonene denne ekstra toleransen, slik at periodevarslingssystemet er mer tilbøyelig til å inkludere dem. 0 = deaktivert. Standard: 0",
|
||||||
|
"peak_price_segment_forcing": "Når aktivert deles dager med M-formet priskurve (to topper adskilt av et sentralt dal) ved det sentrale dalet. Periodedetektor kjøres uavhengig for hver toppside for å sikre at hver topp får det påkrevde antallet perioder.",
|
||||||
|
"peak_price_segment_min_periods": "Minimum antall topprisperioder per toppside når M-form segment-tvinging er aktivert. Hver side må uavhengig produsere minst dette antallet perioder. Standard: 1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -245,12 +245,16 @@
|
||||||
"data": {
|
"data": {
|
||||||
"best_price_extend_to_very_cheap": "Uitbreiden met zeer goedkope intervallen",
|
"best_price_extend_to_very_cheap": "Uitbreiden met zeer goedkope intervallen",
|
||||||
"best_price_max_extension_intervals": "Maximale uitbreidingsintervallen",
|
"best_price_max_extension_intervals": "Maximale uitbreidingsintervallen",
|
||||||
"best_price_geometric_flex": "Geometrische Flex Bonus"
|
"best_price_geometric_flex": "Geometrische Flex Bonus",
|
||||||
|
"best_price_segment_forcing": "W-vorm segmentverstrekking",
|
||||||
|
"best_price_segment_min_periods": "Periodes per segment"
|
||||||
},
|
},
|
||||||
"data_description": {
|
"data_description": {
|
||||||
"best_price_extend_to_very_cheap": "Indien ingeschakeld, breiden gedetecteerde beste-prijsperioden zich uit aan de randen om aangrenzende intervallen met prijsniveau 'Zeer goedkoop' op te nemen. Dit vangt extreem goedkope intervallen op aan de randen van gedetecteerde perioden.",
|
"best_price_extend_to_very_cheap": "Indien ingeschakeld, breiden gedetecteerde beste-prijsperioden zich uit aan de randen om aangrenzende intervallen met prijsniveau 'Zeer goedkoop' op te nemen. Dit vangt extreem goedkope intervallen op aan de randen van gedetecteerde perioden.",
|
||||||
"best_price_max_extension_intervals": "Maximaal aantal extra intervallen per kant (linker en rechter rand). Elk interval is 15 minuten. Voorbeeld: 4 intervallen = maximaal 1 uur uitbreiding per rand. Standaard: 4",
|
"best_price_max_extension_intervals": "Maximaal aantal extra intervallen per kant (linker en rechter rand). Elk interval is 15 minuten. Voorbeeld: 4 intervallen = maximaal 1 uur uitbreiding per rand. Standaard: 4",
|
||||||
"best_price_geometric_flex": "Extra flex percentage voor intervallen die binnen een gedetecteerde prijsdal (V-vorm) vallen. Wanneer een dal-patroon wordt gedetecteerd voor de dag, krijgen intervallen binnen de dalzone deze extra tolerantie, waardoor de periodedetector ze eerder opneemt. 0 = uitgeschakeld. Standaard: 0"
|
"best_price_geometric_flex": "Extra flex percentage voor intervallen die binnen een gedetecteerde prijsdal (V-vorm) vallen. Wanneer een dal-patroon wordt gedetecteerd voor de dag, krijgen intervallen binnen de dalzone deze extra tolerantie, waardoor de periodedetector ze eerder opneemt. 0 = uitgeschakeld. Standaard: 0",
|
||||||
|
"best_price_segment_forcing": "Wanneer ingeschakeld worden dagen met een W-vormige prijscurve (twee dalen gescheiden door een centrale piek) gesplitst op de centrale piek. Periodedetectie wordt onafhankelijk uitgevoerd voor elke dalzijde, zodat elk dal het vereiste aantal perioden heeft.",
|
||||||
|
"best_price_segment_min_periods": "Minimaal aantal beste-prijsperioden per dalzijde wanneer W-vorm segmentverstrekking is ingeschakeld. Elke zijde moet onafhankelijk minimaal dit aantal perioden opleveren. Standaard: 1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -306,12 +310,16 @@
|
||||||
"data": {
|
"data": {
|
||||||
"peak_price_extend_to_very_expensive": "Uitbreiden met zeer dure intervallen",
|
"peak_price_extend_to_very_expensive": "Uitbreiden met zeer dure intervallen",
|
||||||
"peak_price_max_extension_intervals": "Maximale uitbreidingsintervallen",
|
"peak_price_max_extension_intervals": "Maximale uitbreidingsintervallen",
|
||||||
"peak_price_geometric_flex": "Geometrische Flex Bonus"
|
"peak_price_geometric_flex": "Geometrische Flex Bonus",
|
||||||
|
"peak_price_segment_forcing": "M-vorm segmentverstrekking",
|
||||||
|
"peak_price_segment_min_periods": "Periodes per segment"
|
||||||
},
|
},
|
||||||
"data_description": {
|
"data_description": {
|
||||||
"peak_price_extend_to_very_expensive": "Indien ingeschakeld, breiden gedetecteerde piekprijsperioden zich uit aan de randen om aangrenzende intervallen met prijsniveau 'Zeer duur' op te nemen. Dit vangt extreem dure intervallen op aan de randen van gedetecteerde perioden.",
|
"peak_price_extend_to_very_expensive": "Indien ingeschakeld, breiden gedetecteerde piekprijsperioden zich uit aan de randen om aangrenzende intervallen met prijsniveau 'Zeer duur' op te nemen. Dit vangt extreem dure intervallen op aan de randen van gedetecteerde perioden.",
|
||||||
"peak_price_max_extension_intervals": "Maximaal aantal extra intervallen per kant (linker en rechter rand). Elk interval is 15 minuten. Voorbeeld: 4 intervallen = maximaal 1 uur uitbreiding per rand. Standaard: 4",
|
"peak_price_max_extension_intervals": "Maximaal aantal extra intervallen per kant (linker en rechter rand). Elk interval is 15 minuten. Voorbeeld: 4 intervallen = maximaal 1 uur uitbreiding per rand. Standaard: 4",
|
||||||
"peak_price_geometric_flex": "Extra flex percentage voor intervallen die binnen een gedetecteerde prijspiek (Λ-vorm) vallen. Wanneer een piek-patroon wordt gedetecteerd voor de dag, krijgen intervallen binnen de piekzone deze extra tolerantie, waardoor de periodedetector ze eerder opneemt. 0 = uitgeschakeld. Standaard: 0"
|
"peak_price_geometric_flex": "Extra flex percentage voor intervallen die binnen een gedetecteerde prijspiek (Λ-vorm) vallen. Wanneer een piek-patroon wordt gedetecteerd voor de dag, krijgen intervallen binnen de piekzone deze extra tolerantie, waardoor de periodedetector ze eerder opneemt. 0 = uitgeschakeld. Standaard: 0",
|
||||||
|
"peak_price_segment_forcing": "Wanneer ingeschakeld worden dagen met een M-vormige prijscurve (twee pieken gescheiden door een centrale dal) gesplitst op de centrale dal. Periodedetectie wordt onafhankelijk uitgevoerd voor elke piekzijde, zodat elke piek het vereiste aantal perioden heeft.",
|
||||||
|
"peak_price_segment_min_periods": "Minimaal aantal piekprijsperioden per piekzijde wanneer M-vorm segmentverstrekking is ingeschakeld. Elke zijde moet onafhankelijk minimaal dit aantal perioden opleveren. Standaard: 1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -245,12 +245,16 @@
|
||||||
"data": {
|
"data": {
|
||||||
"best_price_extend_to_very_cheap": "Utvidga till mycket billiga intervall",
|
"best_price_extend_to_very_cheap": "Utvidga till mycket billiga intervall",
|
||||||
"best_price_max_extension_intervals": "Maximalt antal utvidgningsintervall",
|
"best_price_max_extension_intervals": "Maximalt antal utvidgningsintervall",
|
||||||
"best_price_geometric_flex": "Geometrisk flexbonus"
|
"best_price_geometric_flex": "Geometrisk flexbonus",
|
||||||
|
"best_price_segment_forcing": "W-form segmenttvingning",
|
||||||
|
"best_price_segment_min_periods": "Perioder per segment"
|
||||||
},
|
},
|
||||||
"data_description": {
|
"data_description": {
|
||||||
"best_price_extend_to_very_cheap": "När aktiverat utvidgas hittade bästa-prisperioder utåt för att inkludera angränsande intervall med prisnivån 'Mycket billig'. Detta fångar upp extremt billiga intervall vid kanterna av hittade perioder.",
|
"best_price_extend_to_very_cheap": "När aktiverat utvidgas hittade bästa-prisperioder utåt för att inkludera angränsande intervall med prisnivån 'Mycket billig'. Detta fångar upp extremt billiga intervall vid kanterna av hittade perioder.",
|
||||||
"best_price_max_extension_intervals": "Maximalt antal extra intervall per sida (vänster och höger kant). Varje intervall är 15 minuter. Exempel: 4 intervall = upp till 1 timmes utvidgning per kant. Standard: 4",
|
"best_price_max_extension_intervals": "Maximalt antal extra intervall per sida (vänster och höger kant). Varje intervall är 15 minuter. Exempel: 4 intervall = upp till 1 timmes utvidgning per kant. Standard: 4",
|
||||||
"best_price_geometric_flex": "Extra flexprocentandel för intervall som faller inom en detekterad prisdal (V-form). När ett dalmönster detekteras för dagen får intervall inom dalzonen denna extra tolerans, vilket gör att perioddektorn är mer benägen att inkludera dem. 0 = inaktiverad. Standard: 0"
|
"best_price_geometric_flex": "Extra flexprocentandel för intervall som faller inom en detekterad prisdal (V-form). När ett dalmönster detekteras för dagen får intervall inom dalzonen denna extra tolerans, vilket gör att perioddektorn är mer benägen att inkludera dem. 0 = inaktiverad. Standard: 0",
|
||||||
|
"best_price_segment_forcing": "När aktiverat delas dagar med W-formad priskurva (två dalar åtskilda av en central topp) vid den centrala toppen. Periodedetektering körs oberoende för varje dalsida för att säkerställa att varje dal får det erforderliga antalet perioder.",
|
||||||
|
"best_price_segment_min_periods": "Minsta antal bästa-prisperioder per dalsida när W-form segmenttvingning är aktiverat. Varje sida måste oberoende producera minst detta antal perioder. Standard: 1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -306,12 +310,16 @@
|
||||||
"data": {
|
"data": {
|
||||||
"peak_price_extend_to_very_expensive": "Utvidga till mycket dyra intervall",
|
"peak_price_extend_to_very_expensive": "Utvidga till mycket dyra intervall",
|
||||||
"peak_price_max_extension_intervals": "Maximalt antal utvidgningsintervall",
|
"peak_price_max_extension_intervals": "Maximalt antal utvidgningsintervall",
|
||||||
"peak_price_geometric_flex": "Geometrisk flexbonus"
|
"peak_price_geometric_flex": "Geometrisk flexbonus",
|
||||||
|
"peak_price_segment_forcing": "M-form segmenttvingning",
|
||||||
|
"peak_price_segment_min_periods": "Perioder per segment"
|
||||||
},
|
},
|
||||||
"data_description": {
|
"data_description": {
|
||||||
"peak_price_extend_to_very_expensive": "När aktiverat utvidgas hittade topprisperioder utåt för att inkludera angränsande intervall med prisnivån 'Mycket dyr'. Detta fångar upp extremt dyra intervall vid kanterna av hittade perioder.",
|
"peak_price_extend_to_very_expensive": "När aktiverat utvidgas hittade topprisperioder utåt för att inkludera angränsande intervall med prisnivån 'Mycket dyr'. Detta fångar upp extremt dyra intervall vid kanterna av hittade perioder.",
|
||||||
"peak_price_max_extension_intervals": "Maximalt antal extra intervall per sida (vänster och höger kant). Varje intervall är 15 minuter. Exempel: 4 intervall = upp till 1 timmes utvidgning per kant. Standard: 4",
|
"peak_price_max_extension_intervals": "Maximalt antal extra intervall per sida (vänster och höger kant). Varje intervall är 15 minuter. Exempel: 4 intervall = upp till 1 timmes utvidgning per kant. Standard: 4",
|
||||||
"peak_price_geometric_flex": "Extra flexprocentandel för intervall som faller inom en detekterad prispeak (Λ-form). När ett peak-mönster detekteras för dagen får intervall inom peakzonen denna extra tolerans, vilket gör att perioddetektor är mer benägen att inkludera dem. 0 = inaktiverad. Standard: 0"
|
"peak_price_geometric_flex": "Extra flexprocentandel för intervall som faller inom en detekterad prispeak (Λ-form). När ett peak-mönster detekteras för dagen får intervall inom peakzonen denna extra tolerans, vilket gör att perioddetektor är mer benägen att inkludera dem. 0 = inaktiverad. Standard: 0",
|
||||||
|
"peak_price_segment_forcing": "När aktiverat delas dagar med M-formad priskurva (två toppar åtskilda av en central dal) vid den centrala dalen. Periodedetektering körs oberoende för varje toppsida för att säkerställa att varje topp får det erforderliga antalet perioder.",
|
||||||
|
"peak_price_segment_min_periods": "Minsta antal topprisperioder per toppsida när M-form segmenttvingning är aktiverat. Varje sida måste oberoende producera minst detta antal perioder. Standard: 1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -834,8 +834,7 @@ INFO: Day 2025-11-11: Baseline satisfied (1 period, effective minimum is 1)
|
||||||
**Sensor Attributes:**
|
**Sensor Attributes:**
|
||||||
```yaml
|
```yaml
|
||||||
min_periods_configured: 2 # User's setting
|
min_periods_configured: 2 # User's setting
|
||||||
periods_found_total: 1 # Actual result
|
flat_days_detected: 1 # Explains why only 1 period found
|
||||||
flat_days_detected: 1 # Explains the difference
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**Why not for Peak Price?**
|
**Why not for Peak Price?**
|
||||||
|
|
@ -957,7 +956,6 @@ When debugging period calculation issues:
|
||||||
| Attribute | Type | When shown | Meaning |
|
| Attribute | Type | When shown | Meaning |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `min_periods_configured` | int | Always | User's configured target per day |
|
| `min_periods_configured` | int | Always | User's configured target per day |
|
||||||
| `periods_found_total` | int | Always | Actual periods found across all days |
|
|
||||||
| `flat_days_detected` | int | Only when > 0 | Days where CV ≤ 10% reduced target to 1 |
|
| `flat_days_detected` | int | Only when > 0 | Days where CV ≤ 10% reduced target to 1 |
|
||||||
| `relaxation_incomplete` | bool | Only when true | Relaxation exhausted, target not reached |
|
| `relaxation_incomplete` | bool | Only when true | Relaxation exhausted, target not reached |
|
||||||
| `relaxation_active` | bool | Only when true | This specific period needed relaxed filters |
|
| `relaxation_active` | bool | Only when true | This specific period needed relaxed filters |
|
||||||
|
|
|
||||||
|
|
@ -137,7 +137,7 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
|
||||||
|
|
||||||
### 7. Redundant/Derived Data
|
### 7. Redundant/Derived Data
|
||||||
|
|
||||||
**Attributes:** `price_spread`, `volatility`, `diff_%`, `rating_difference_%`, `period_price_diff_from_daily_min`, `period_price_diff_from_daily_min_%`, `periods_total`, `periods_remaining`
|
**Attributes:** `price_spread`, `volatility`, `diff_%`, `rating_difference_%`, `period_price_diff_from_daily_min`, `period_price_diff_from_daily_min_%`, `period_count_total`, `periods_remaining`
|
||||||
|
|
||||||
**Reason:**
|
**Reason:**
|
||||||
- Can be calculated from other attributes
|
- Can be calculated from other attributes
|
||||||
|
|
@ -146,7 +146,7 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
|
||||||
|
|
||||||
**Impact:** ~100-200 bytes saved per state change
|
**Impact:** ~100-200 bytes saved per state change
|
||||||
|
|
||||||
**Example:** `price_spread = price_max - price_min` (both are recorded, so spread can be calculated)
|
**Example:** `price_spread = price_max - price_min` (both are recorded, so spread can be calculated). `periods_remaining = period_count_total - period_position` (both components are recorded).
|
||||||
|
|
||||||
## Attributes That ARE Recorded
|
## Attributes That ARE Recorded
|
||||||
|
|
||||||
|
|
@ -166,6 +166,8 @@ These attributes **remain in history** because they provide essential analytical
|
||||||
### Period Data
|
### Period Data
|
||||||
- `start`, `end`, `duration_minutes` - Core period timing
|
- `start`, `end`, `duration_minutes` - Core period timing
|
||||||
- `price_mean`, `price_median`, `price_min`, `price_max` - Core price statistics
|
- `price_mean`, `price_median`, `price_min`, `price_max` - Core price statistics
|
||||||
|
- `period_position` - Position of current period in the day's sequence
|
||||||
|
- `period_count_today`, `period_count_tomorrow` - How many periods per day (useful in automations)
|
||||||
|
|
||||||
### High-Level Status
|
### High-Level Status
|
||||||
- `relaxation_active` - Whether relaxation was used (boolean, useful for analyzing when periods needed relaxation)
|
- `relaxation_active` - Whether relaxation was used (boolean, useful for analyzing when periods needed relaxation)
|
||||||
|
|
|
||||||
|
|
@ -523,7 +523,7 @@ This is **expected behavior** on days with very uniform electricity prices. When
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
min_periods_configured: 2
|
min_periods_configured: 2
|
||||||
periods_found_total: 1
|
period_count_today: 1
|
||||||
flat_days_detected: 1 # Uniform prices today → 1 period is the right answer
|
flat_days_detected: 1 # Uniform prices today → 1 period is the right answer
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -656,7 +656,8 @@ relaxation_level: "price_diff_18.0%+level_any" # Found at 18% flex, level filte
|
||||||
|
|
||||||
# Calculation summary (always shown – diagnostic overview of this calculation run):
|
# Calculation summary (always shown – diagnostic overview of this calculation run):
|
||||||
min_periods_configured: 2 # What you configured as target
|
min_periods_configured: 2 # What you configured as target
|
||||||
periods_found_total: 3 # What was actually found across all days
|
period_count_today: 2 # How many periods are scheduled today
|
||||||
|
period_count_tomorrow: 2 # How many periods are scheduled tomorrow (when data available)
|
||||||
|
|
||||||
# Optional (only shown when relevant):
|
# Optional (only shown when relevant):
|
||||||
period_interval_smoothed_count: 2 # Number of price spikes smoothed
|
period_interval_smoothed_count: 2 # Number of price spikes smoothed
|
||||||
|
|
@ -669,7 +670,7 @@ relaxation_incomplete: true # Some days couldn't reach the configured ta
|
||||||
|
|
||||||
#### What the diagnostic attributes mean
|
#### What the diagnostic attributes mean
|
||||||
|
|
||||||
**`min_periods_configured` / `periods_found_total`**
|
**`min_periods_configured` / `period_count_today`**
|
||||||
|
|
||||||
These two values together quickly show whether the calculation achieved its goal:
|
These two values together quickly show whether the calculation achieved its goal:
|
||||||
|
|
||||||
|
|
@ -678,18 +679,18 @@ These two values together quickly show whether the calculation achieved its goal
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
min_periods_configured: 2 # You asked for 2 periods per day
|
min_periods_configured: 2 # You asked for 2 periods per day
|
||||||
periods_found_total: 6 # 3 days × 2 periods = fully satisfied ✅
|
period_count_today: 2 # ✅ Today: target reached
|
||||||
|
period_count_tomorrow: 2 # ✅ Tomorrow: target reached
|
||||||
```
|
```
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
min_periods_configured: 2
|
min_periods_configured: 2
|
||||||
periods_found_total: 5 # 3 days, but one day got only 1 period
|
period_count_today: 1 # ⚠️ Today: only 1 period found
|
||||||
|
period_count_tomorrow: 2 # ✅ Tomorrow: target reached
|
||||||
```
|
```
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
Note that `periods_found_total` counts **all periods across today and tomorrow** – so 4 on a two-day view means 2 per day on average.
|
|
||||||
|
|
||||||
**`flat_days_detected`**
|
**`flat_days_detected`**
|
||||||
|
|
||||||
This is the most important diagnostic for days with very uniform prices (e.g. sunny spring/summer days with high solar generation):
|
This is the most important diagnostic for days with very uniform prices (e.g. sunny spring/summer days with high solar generation):
|
||||||
|
|
@ -699,7 +700,7 @@ This is the most important diagnostic for days with very uniform prices (e.g. su
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
min_periods_configured: 2
|
min_periods_configured: 2
|
||||||
periods_found_total: 1
|
period_count_today: 1
|
||||||
flat_days_detected: 1 # ← This explains why you got 1 instead of 2
|
flat_days_detected: 1 # ← This explains why you got 1 instead of 2
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -718,7 +719,7 @@ This flag appears when even after all relaxation attempts, at least one day coul
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
min_periods_configured: 2
|
min_periods_configured: 2
|
||||||
periods_found_total: 1
|
period_count_today: 1
|
||||||
relaxation_incomplete: true # ← Relaxation tried everything, still short
|
relaxation_incomplete: true # ← Relaxation tried everything, still short
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -130,7 +130,7 @@ Check the period sensor attributes to understand what happened:
|
||||||
relaxation_active: true # This day needed relaxation
|
relaxation_active: true # This day needed relaxation
|
||||||
relaxation_level: "price_diff_18.0%+level_any" # Found at 18% flex, level filter removed
|
relaxation_level: "price_diff_18.0%+level_any" # Found at 18% flex, level filter removed
|
||||||
min_periods_configured: 2 # Your target
|
min_periods_configured: 2 # Your target
|
||||||
periods_found_total: 3 # What was actually found
|
period_count_today: 3 # What was actually found today
|
||||||
```
|
```
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue