Compare commits

..

No commits in common. "779e22a84e111d2df65ce037a6084f91b86fe256" and "4ddd19b1324e184a1f9f3d55d049007291cc4c65" have entirely different histories.

23 changed files with 83 additions and 509 deletions

View file

@ -119,19 +119,6 @@ def get_price_intervals_attributes(
if not filtered_periods:
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
current_period = None
@ -264,8 +251,8 @@ def add_detail_attributes(attributes: dict, current_period: dict) -> None:
attributes["period_interval_count"] = current_period["period_interval_count"]
if "period_position" in current_period:
attributes["period_position"] = current_period["period_position"]
if "period_count_total" in current_period:
attributes["period_count_total"] = current_period["period_count_total"]
if "periods_total" in current_period:
attributes["periods_total"] = current_period["periods_total"]
if "periods_remaining" in current_period:
attributes["periods_remaining"] = current_period["periods_remaining"]
@ -311,6 +298,7 @@ def add_period_count_attributes(
count_tomorrow += 1
_ = now # used for clarity only
if count_today > 0 or count_tomorrow > 0:
attributes["period_count_today"] = count_today
attributes["period_count_tomorrow"] = count_tomorrow
@ -336,11 +324,13 @@ def add_calculation_summary_attributes(attributes: dict, period_metadata: dict)
"""
Add calculation summary attributes (priority 7).
Provides diagnostic visibility into the period calculation: whether any flat days
triggered adaptive min_periods, and whether relaxation could not satisfy all days.
Provides diagnostic visibility into the period calculation: how many periods
were requested vs. found, whether any flat days triggered adaptive min_periods,
and whether relaxation could not satisfy all days.
Only adds non-default/interesting values to avoid clutter:
- 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)
- relaxation_incomplete: only when True (diagnostic flag for troubleshooting)
@ -352,6 +342,9 @@ def add_calculation_summary_attributes(attributes: dict, period_metadata: dict)
if "min_periods_requested" in relaxation_meta:
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)
if flat_days > 0:
attributes["flat_days_detected"] = flat_days
@ -421,10 +414,10 @@ def build_final_attributes_simple(
2. Core decision attributes (level, rating_level, rating_difference_%)
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_%)
5. Detail information (period_interval_count, period_position, period_count_total, periods_remaining)
5. Detail information (period_interval_count, period_position, periods_total, periods_remaining)
6. Relaxation information (relaxation_active, relaxation_level, relaxation_threshold_original_%,
relaxation_threshold_applied_%) - only if current period was relaxed
7. Calculation summary (min_periods_configured, flat_days_detected,
7. Calculation summary (min_periods_configured, periods_found_total, flat_days_detected,
relaxation_incomplete) - diagnostic info about the overall calculation
8. Meta information (periods list)
@ -471,7 +464,7 @@ def build_final_attributes_simple(
# 6. Relaxation information (only if current period was relaxed)
add_relaxation_attributes(attributes, current_period)
# 7. Calculation summary (diagnostic: min_periods_configured, flat_days_detected, etc.)
# 7. Calculation summary (diagnostic: min_periods_configured, periods_found_total, etc.)
if period_metadata:
add_calculation_summary_attributes(attributes, period_metadata)

View file

@ -64,6 +64,7 @@ class TibberPricesBinarySensor(TibberPricesEntity, BinarySensorEntity, RestoreEn
"relaxation_threshold_applied_%",
# Calculation Summary (diagnostic, changes daily → not useful in history)
"min_periods_configured",
"periods_found_total",
"flat_days_detected",
"relaxation_incomplete",
# Redundant/Derived
@ -72,7 +73,7 @@ class TibberPricesBinarySensor(TibberPricesEntity, BinarySensorEntity, RestoreEn
"rating_difference_%",
"period_price_diff_from_daily_min",
"period_price_diff_from_daily_min_%",
"period_count_total",
"periods_total",
"periods_remaining",
}
)

View file

@ -104,7 +104,7 @@ class PeriodSummary(TypedDict, total=False):
# Detail information (priority 5)
period_interval_count: int # Number of intervals in period
period_position: int # Period position (1-based)
period_count_total: int # Total number of periods
periods_total: int # Total number of periods
periods_remaining: int # Remaining periods after this one
# 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_%)
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_%)
5. Detail information (period_interval_count, period_position, period_count_total, periods_remaining)
5. Detail information (period_interval_count, period_position, periods_total, periods_remaining)
6. Relaxation information (only if period was relaxed)
7. Meta information (periods list)
"""
@ -155,7 +155,7 @@ class PeriodAttributes(BaseAttributes, total=False):
# Detail information (priority 5)
period_interval_count: int # Number of intervals in current/next period
period_position: int # Period position (1-based)
period_count_total: int # Total number of periods found
periods_total: int # Total number of periods found
periods_remaining: int # Remaining periods after current/next one
# Relaxation information (priority 6 - only if period was relaxed)

View file

@ -378,6 +378,7 @@ class TibberPricesOptionsFlowHandler(OptionsFlow):
# Load template and connector from common section
template = await async_get_translation(self.hass, ["common", "override_warning_template"], language)
_LOGGER.debug("Loaded template: %s", template)
if template:
translations["override_warning_template"] = template
@ -644,8 +645,8 @@ class TibberPricesOptionsFlowHandler(OptionsFlow):
placeholders = self._get_entity_warning_placeholders("best_price")
placeholders.update(self._get_override_warning_placeholder("best_price", overrides))
# Load translations for override warnings only when overrides are active
override_translations = await self._get_override_translations() if overrides else {}
# Load translations for override warnings
override_translations = await self._get_override_translations()
return self.async_show_form(
step_id="best_price",
@ -716,8 +717,8 @@ class TibberPricesOptionsFlowHandler(OptionsFlow):
placeholders = self._get_entity_warning_placeholders("peak_price")
placeholders.update(self._get_override_warning_placeholder("peak_price", overrides))
# Load translations for override warnings only when overrides are active
override_translations = await self._get_override_translations() if overrides else {}
# Load translations for override warnings
override_translations = await self._get_override_translations()
return self.async_show_form(
step_id="peak_price",

View file

@ -20,8 +20,6 @@ from custom_components.tibber_prices.const import (
CONF_BEST_PRICE_MAX_LEVEL_GAP_COUNT,
CONF_BEST_PRICE_MIN_DISTANCE_FROM_AVG,
CONF_BEST_PRICE_MIN_PERIOD_LENGTH,
CONF_BEST_PRICE_SEGMENT_FORCING,
CONF_BEST_PRICE_SEGMENT_MIN_PERIODS,
CONF_CURRENCY_DISPLAY_MODE,
CONF_ENABLE_MIN_PERIODS_BEST,
CONF_ENABLE_MIN_PERIODS_PEAK,
@ -36,8 +34,6 @@ from custom_components.tibber_prices.const import (
CONF_PEAK_PRICE_MIN_DISTANCE_FROM_AVG,
CONF_PEAK_PRICE_MIN_LEVEL,
CONF_PEAK_PRICE_MIN_PERIOD_LENGTH,
CONF_PEAK_PRICE_SEGMENT_FORCING,
CONF_PEAK_PRICE_SEGMENT_MIN_PERIODS,
CONF_PRICE_LEVEL_GAP_TOLERANCE,
CONF_PRICE_RATING_GAP_TOLERANCE,
CONF_PRICE_RATING_HYSTERESIS,
@ -67,8 +63,6 @@ from custom_components.tibber_prices.const import (
DEFAULT_BEST_PRICE_MAX_LEVEL_GAP_COUNT,
DEFAULT_BEST_PRICE_MIN_DISTANCE_FROM_AVG,
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_PEAK,
DEFAULT_EXTENDED_DESCRIPTIONS,
@ -82,8 +76,6 @@ from custom_components.tibber_prices.const import (
DEFAULT_PEAK_PRICE_MIN_DISTANCE_FROM_AVG,
DEFAULT_PEAK_PRICE_MIN_LEVEL,
DEFAULT_PEAK_PRICE_MIN_PERIOD_LENGTH,
DEFAULT_PEAK_PRICE_SEGMENT_FORCING,
DEFAULT_PEAK_PRICE_SEGMENT_MIN_PERIODS,
DEFAULT_PRICE_LEVEL_GAP_TOLERANCE,
DEFAULT_PRICE_RATING_GAP_TOLERANCE,
DEFAULT_PRICE_RATING_HYSTERESIS,
@ -124,7 +116,6 @@ from custom_components.tibber_prices.const import (
MAX_PRICE_TREND_STRONGLY_FALLING,
MAX_PRICE_TREND_STRONGLY_RISING,
MAX_RELAXATION_ATTEMPTS,
MAX_SEGMENT_MIN_PERIODS,
MAX_VOLATILITY_THRESHOLD_HIGH,
MAX_VOLATILITY_THRESHOLD_MODERATE,
MAX_VOLATILITY_THRESHOLD_VERY_HIGH,
@ -664,12 +655,6 @@ def get_best_price_schema(
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))
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
period_warning = get_section_override_warning("best_price", "period_settings", overrides, translations) or {}
@ -821,21 +806,6 @@ def get_best_price_schema(
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},
@ -888,12 +858,6 @@ def get_peak_price_schema(
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))
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
period_warning = get_section_override_warning("peak_price", "period_settings", overrides, translations) or {}
@ -1045,21 +1009,6 @@ def get_peak_price_schema(
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},

View file

@ -74,10 +74,6 @@ CONF_BEST_PRICE_GEOMETRIC_FLEX = "best_price_geometric_flex"
CONF_PEAK_PRICE_EXTEND_TO_VERY_EXPENSIVE = "peak_price_extend_to_very_expensive"
CONF_PEAK_PRICE_MAX_EXTENSION_INTERVALS = "peak_price_max_extension_intervals"
CONF_PEAK_PRICE_GEOMETRIC_FLEX = "peak_price_geometric_flex"
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"
@ -147,10 +143,6 @@ 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_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_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)
# These ensure consistency between frontend and backend validation
@ -161,7 +153,6 @@ 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_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_SEGMENT_MIN_PERIODS = 5 # Maximum per-segment minimum periods (GUI slider limit)
MIN_PERIOD_LENGTH = 15 # Minimum period length in minutes (1 quarter hour)
MAX_MIN_PERIOD_LENGTH = 180 # Maximum for minimum period length setting (3 hours - realistic for required minimum)
@ -435,13 +426,9 @@ 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_MAX_EXTENSION_INTERVALS: DEFAULT_BEST_PRICE_MAX_EXTENSION_INTERVALS,
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_MAX_EXTENSION_INTERVALS: DEFAULT_PEAK_PRICE_MAX_EXTENSION_INTERVALS,
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,
},
}

View file

@ -5,8 +5,6 @@ from __future__ import annotations
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from datetime import datetime
from custom_components.tibber_prices.coordinator.time_service import TibberPricesTimeService
from .types import TibberPricesPeriodConfig
@ -33,7 +31,6 @@ from .types import TibberPricesThresholdConfig
# 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_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(
@ -42,7 +39,6 @@ def calculate_periods(
config: TibberPricesPeriodConfig,
time: TibberPricesTimeService,
day_patterns_by_date: dict | None = None,
time_range: tuple[datetime, datetime] | None = None,
) -> dict[str, Any]:
"""
Calculate price periods (best or peak) from price data.
@ -65,9 +61,6 @@ def calculate_periods(
min_period_length, threshold_low, and threshold_high.
time: TibberPricesTimeService instance (required).
day_patterns_by_date: Optional dict mapping date day pattern dict for geometric flex bonus.
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:
Dict with:
@ -175,7 +168,6 @@ def calculate_periods(
level_filter=config.level_filter,
gap_count=config.gap_count,
time=time,
time_range=time_range,
)
_LOGGER.debug(
@ -186,24 +178,6 @@ def calculate_periods(
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
raw_periods = filter_periods_by_min_length(raw_periods, min_period_length, time=time)
_LOGGER.debug(
@ -290,168 +264,3 @@ def calculate_periods(
"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

View file

@ -62,7 +62,6 @@ def build_periods( # noqa: PLR0913, PLR0915, PLR0912 - Complex period building
level_filter: str | None = None,
gap_count: int = 0,
time: TibberPricesTimeService,
time_range: tuple[datetime, datetime] | None = None,
) -> list[list[dict]]:
"""
Build periods, allowing periods to cross midnight (day boundary).
@ -79,10 +78,6 @@ def build_periods( # noqa: PLR0913, PLR0915, PLR0912 - Complex period building
level_filter: Level filter string ("cheap", "expensive", "any", None)
gap_count: Number of allowed consecutive intervals deviating by exactly 1 level step
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"]
@ -137,11 +132,6 @@ def build_periods( # noqa: PLR0913, PLR0915, PLR0912 - Complex period building
starts_at = time.get_interval_time(price_data)
if starts_at is None:
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()
# Use smoothed price for criteria checks (flex/distance)

View file

@ -56,7 +56,7 @@ def recalculate_period_metadata(periods: list[dict], *, time: TibberPricesTimeSe
"""
Recalculate period metadata after merging periods.
Updates period_position, period_count_total, and periods_remaining for all periods
Updates period_position, periods_total, and periods_remaining for all periods
based on chronological order.
This must be called after resolve_period_overlaps() to ensure metadata
@ -78,7 +78,7 @@ def recalculate_period_metadata(periods: list[dict], *, time: TibberPricesTimeSe
for position, period in enumerate(periods, 1):
period["period_position"] = position
period["period_count_total"] = total_periods
period["periods_total"] = total_periods
period["periods_remaining"] = total_periods - position

View file

@ -23,8 +23,6 @@ from custom_components.tibber_prices.utils.price import (
calculate_volatility_level,
)
from .types import LOW_PRICE_QUALITY_BYPASS_THRESHOLD, PERIOD_MAX_CV
def calculate_period_price_diff(
price_mean: float,
@ -178,7 +176,7 @@ def build_period_summary_dict(
# 5. Detail information (additional context)
"period_interval_count": period_data.period_length,
"period_position": period_data.period_idx,
"period_count_total": period_data.total_periods,
"periods_total": period_data.total_periods,
"periods_remaining": period_data.total_periods - period_data.period_idx,
}
@ -222,57 +220,18 @@ def build_period_summary_dict(
return summary
def _strip_geo_from_edges(period: list[dict]) -> list[dict]:
"""
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.
"""
def _add_interval_flag_counts(summary: dict, period: list[dict]) -> None:
"""Add optional interval flag counts to period summary."""
if (count := sum(1 for i in period if i.get("smoothing_was_impactful", False))) > 0:
summary["period_interval_smoothed_count"] = count
if (count := sum(1 for i in period if i.get("is_level_gap", False))) > 0:
summary["period_interval_level_gap_count"] = count
# 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))
if (count := sum(1 for i in period if i.get("geometric_bonus_applied", False))) > 0:
summary["geometric_extension_active"] = True
summary["geometric_extension_intervals"] = count
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( # noqa: PLR0912, PLR0915 - CV pre-check for geo-extension adds necessary branches/statements
def extract_period_summaries(
periods: list[list[dict]],
all_prices: list[dict],
price_context: dict[str, Any],
@ -321,34 +280,6 @@ def extract_period_summaries( # noqa: PLR0912, PLR0915 - CV pre-check for geo-e
if not period:
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]
last_interval = period[-1]
@ -438,7 +369,7 @@ def extract_period_summaries( # noqa: PLR0912, PLR0915 - CV pre-check for geo-e
)
# Add optional interval flag counts (smoothing, level gaps, geometric extension)
_add_interval_flag_counts(summary, period, geo_extension_status=geo_extension_status)
_add_interval_flag_counts(summary, period)
summaries.append(summary)

View file

@ -8,7 +8,7 @@ from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from collections.abc import Callable
from datetime import date, datetime
from datetime import date
from custom_components.tibber_prices.coordinator.time_service import TibberPricesTimeService
@ -22,7 +22,6 @@ from .types import (
INDENT_L0,
INDENT_L1,
INDENT_L2,
LOW_PRICE_QUALITY_BYPASS_THRESHOLD,
PERIOD_MAX_CV,
TibberPricesPeriodConfig,
)
@ -42,6 +41,12 @@ 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_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.
# When span / ref_price < this on ANY available day, the warning is shown.
@ -522,7 +527,6 @@ def calculate_periods_with_relaxation( # noqa: PLR0912, PLR0913, PLR0915 - Per-
time: TibberPricesTimeService,
config_entry: Any, # ConfigEntry type
day_patterns_by_date: dict | None = None,
time_range: tuple[datetime, datetime] | None = None,
) -> dict[str, Any]:
"""
Calculate periods with optional per-day filter relaxation.
@ -551,9 +555,6 @@ def calculate_periods_with_relaxation( # noqa: PLR0912, PLR0913, PLR0915 - Per-
config_entry: Config entry to get display unit configuration.
day_patterns_by_date: Optional dict mapping date day pattern dict. Used for
geometric flex bonus in period detection. Passed through to calculate_periods().
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:
Dict with same format as calculate_periods() output:
@ -639,6 +640,7 @@ def calculate_periods_with_relaxation( # noqa: PLR0912, PLR0913, PLR0915 - Per-
"relaxation_active": False,
"relaxation_attempted": False,
"min_periods_requested": min_periods if enable_relaxation else 0,
"periods_found": 0,
},
},
"reference_data": {},
@ -710,9 +712,7 @@ def calculate_periods_with_relaxation( # noqa: PLR0912, PLR0913, PLR0915 - Per-
# === BASELINE CALCULATION (process ALL prices together, including yesterday) ===
# Periods that ended before yesterday will be filtered out later by filter_periods_by_end_date()
# This keeps yesterday/today/tomorrow periods in the cache
baseline_result = calculate_periods(
all_prices, config=config, time=time, day_patterns_by_date=day_patterns_by_date, time_range=time_range
)
baseline_result = calculate_periods(all_prices, config=config, time=time, day_patterns_by_date=day_patterns_by_date)
all_periods = baseline_result["periods"]
# Count periods per day for min_periods check
@ -839,6 +839,8 @@ def calculate_periods_with_relaxation( # noqa: PLR0912, PLR0913, PLR0915 - Per-
final_result = baseline_result.copy()
final_result["periods"] = all_periods
total_periods = len(all_periods)
# Add relaxation info to metadata
if "metadata" not in final_result:
final_result["metadata"] = {}
@ -846,6 +848,7 @@ def calculate_periods_with_relaxation( # noqa: PLR0912, PLR0913, PLR0915 - Per-
"relaxation_active": relaxation_was_needed,
"relaxation_attempted": relaxation_was_needed,
"min_periods_requested": min_periods,
"periods_found": total_periods,
"phases_used": list(set(all_phases_used)), # Unique phases used across all days
"days_processed": total_days,
"days_meeting_requirement": days_meeting_requirement,
@ -952,10 +955,7 @@ def relax_all_prices( # noqa: PLR0913 - Comprehensive filter relaxation require
# Process ALL prices together (allows midnight crossing)
result = calculate_periods(
all_prices,
config=relaxed_config,
time=time,
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"]
@ -1019,4 +1019,5 @@ def relax_all_prices( # noqa: PLR0913 - Comprehensive filter relaxation require
return final_result, {
"phases_used": phases_used,
"periods_found": len(existing_periods),
}

View file

@ -22,13 +22,6 @@ from custom_components.tibber_prices.const import (
# 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
# 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
# When a period ends late in the day and tomorrow data is available,
# we can extend it past midnight if prices remain favorable
@ -66,8 +59,6 @@ class TibberPricesPeriodConfig(NamedTuple):
extend_to_extreme: bool = False # Extend periods into adjacent VERY_CHEAP/VERY_EXPENSIVE intervals
max_extension_intervals: int = 0 # Max intervals this extension may add per side (0 = disabled)
geometric_extra_flex: float = 0.0 # Extra flex (decimal) for intervals inside the valley/peak zone (0.0 = disabled)
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):

View file

@ -280,40 +280,6 @@ class TibberPricesPeriodCalculator:
)
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
self._config_cache[cache_key] = config
self._config_cache_valid = True
@ -808,8 +774,6 @@ class TibberPricesPeriodCalculator:
extend_to_extreme=best_config["extend_to_extreme"],
max_extension_intervals=best_config["max_extension_intervals"],
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(
all_prices,
@ -895,8 +859,6 @@ class TibberPricesPeriodCalculator:
extend_to_extreme=peak_config["extend_to_extreme"],
max_extension_intervals=peak_config["max_extension_intervals"],
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(
all_prices,

View file

@ -172,7 +172,7 @@ class TibberPricesSensor(TibberPricesEntity, RestoreSensor):
"rating_difference_%",
"period_price_diff_from_daily_min",
"period_price_diff_from_daily_min_%",
"period_count_total",
"periods_total",
"periods_remaining",
}
)

View file

@ -245,16 +245,12 @@
"data": {
"best_price_extend_to_very_cheap": "Auf sehr günstige Intervalle erweitern",
"best_price_max_extension_intervals": "Maximale Erweiterungsintervalle",
"best_price_geometric_flex": "Geometrischer Flex-Bonus",
"best_price_segment_forcing": "W-Form-Segment-Erzwingung",
"best_price_segment_min_periods": "Perioden pro Segment"
"best_price_geometric_flex": "Geometrischer Flex-Bonus"
},
"data_description": {
"best_price_extend_to_very_cheap": "Wenn aktiviert, erweitern sich erkannte Bestpreisperioden nach außen, um angrenzende Intervalle mit dem Preisniveau 'Sehr günstig' aufzunehmen. So werden extrem günstige Intervalle an den Rändern erkannter Perioden besser erfasst.",
"best_price_max_extension_intervals": "Maximale Anzahl zusätzlicher Intervalle pro Seite (linker und rechter Rand). Jedes Intervall dauert 15 Minuten. Beispiel: 4 Intervalle = bis zu 1 Stunde Erweiterung pro Rand. Standard: 4",
"best_price_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"
"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"
}
}
},
@ -310,16 +306,12 @@
"data": {
"peak_price_extend_to_very_expensive": "Auf sehr teure Intervalle erweitern",
"peak_price_max_extension_intervals": "Maximale Erweiterungsintervalle",
"peak_price_geometric_flex": "Geometrischer Flex-Bonus",
"peak_price_segment_forcing": "M-Form-Segment-Erzwingung",
"peak_price_segment_min_periods": "Perioden pro Segment"
"peak_price_geometric_flex": "Geometrischer Flex-Bonus"
},
"data_description": {
"peak_price_extend_to_very_expensive": "Wenn aktiviert, erweitern sich erkannte Spitzenpreisperioden nach außen, um angrenzende Intervalle mit dem Preisniveau 'Sehr teuer' aufzunehmen. So werden extrem teure Intervalle an den Rändern erkannter Perioden besser erfasst.",
"peak_price_max_extension_intervals": "Maximale Anzahl zusätzlicher Intervalle pro Seite (linker und rechter Rand). Jedes Intervall dauert 15 Minuten. Beispiel: 4 Intervalle = bis zu 1 Stunde Erweiterung pro Rand. Standard: 4",
"peak_price_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"
"peak_price_geometric_flex": "Zusätzlicher Flex-Prozentsatz für Intervalle, die in einen erkannten Preisplateau (Λ-Form) fallen. Wenn für den Tag ein Gipfel-Muster erkannt wird, erhalten Intervalle innerhalb der Gipfelzone diese zusätzliche Toleranz, damit der Periodendetektor sie eher einschließt. 0 = deaktiviert. Standard: 0"
}
}
},

View file

@ -256,16 +256,12 @@
"data": {
"best_price_extend_to_very_cheap": "Extend to Very Cheap Intervals",
"best_price_max_extension_intervals": "Maximum Extension Intervals",
"best_price_geometric_flex": "Geometric Flex Bonus",
"best_price_segment_forcing": "W-Shape Segment Forcing",
"best_price_segment_min_periods": "Periods per Segment"
"best_price_geometric_flex": "Geometric Flex Bonus"
},
"data_description": {
"best_price_extend_to_very_cheap": "When enabled, detected best price periods expand outward to absorb adjacent intervals with a 'Very cheap' price level. This widens low-price windows to better capture extremely cheap intervals at the edges of detected periods.",
"best_price_max_extension_intervals": "Maximum number of additional intervals to absorb per side (left and right edge). Each interval is 15 minutes. Example: 4 intervals = up to 1 hour extension per edge. Default: 4",
"best_price_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"
"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"
}
}
},
@ -321,16 +317,12 @@
"data": {
"peak_price_extend_to_very_expensive": "Extend to Very Expensive Intervals",
"peak_price_max_extension_intervals": "Maximum Extension Intervals",
"peak_price_geometric_flex": "Geometric Flex Bonus",
"peak_price_segment_forcing": "M-Shape Segment Forcing",
"peak_price_segment_min_periods": "Periods per Segment"
"peak_price_geometric_flex": "Geometric Flex Bonus"
},
"data_description": {
"peak_price_extend_to_very_expensive": "When enabled, detected peak price periods expand outward to absorb adjacent intervals with a 'Very expensive' price level. This widens high-price windows to better capture extremely expensive intervals at the edges of detected periods.",
"peak_price_max_extension_intervals": "Maximum number of additional intervals to absorb per side (left and right edge). Each interval is 15 minutes. Example: 4 intervals = up to 1 hour extension per edge. Default: 4",
"peak_price_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"
"peak_price_geometric_flex": "Extra flex percentage applied to intervals that fall inside a detected price peak (Λ-shape). When a peak pattern is detected for the day, intervals within the peak zone get this additional tolerance, making the period detector more likely to include them. 0 = disabled. Default: 0"
}
}
},

View file

@ -245,16 +245,12 @@
"data": {
"best_price_extend_to_very_cheap": "Utvid til svært billige intervaller",
"best_price_max_extension_intervals": "Maksimale utvidelsesintervaller",
"best_price_geometric_flex": "Geometrisk fleksbonus",
"best_price_segment_forcing": "W-form segment-tvinging",
"best_price_segment_min_periods": "Perioder per segment"
"best_price_geometric_flex": "Geometrisk fleksbonus"
},
"data_description": {
"best_price_extend_to_very_cheap": "Når aktivert, utvider oppdagede bestprisperioder seg utover for å inkludere tilstøtende intervaller med prisnivået 'Svært billig'. Dette fanger opp ekstremt billige intervaller ved kantene av oppdagede perioder.",
"best_price_max_extension_intervals": "Maksimalt antall ekstra intervaller per side (venstre og høyre kant). Hvert intervall er 15 minutter. Eksempel: 4 intervaller = opptil 1 times utvidelse per kant. Standard: 4",
"best_price_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"
"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"
}
}
},
@ -310,16 +306,12 @@
"data": {
"peak_price_extend_to_very_expensive": "Utvid til svært dyre intervaller",
"peak_price_max_extension_intervals": "Maksimale utvidelsesintervaller",
"peak_price_geometric_flex": "Geometrisk fleksbonus",
"peak_price_segment_forcing": "M-form segment-tvinging",
"peak_price_segment_min_periods": "Perioder per segment"
"peak_price_geometric_flex": "Geometrisk fleksbonus"
},
"data_description": {
"peak_price_extend_to_very_expensive": "Når aktivert, utvider oppdagede topprisperioder seg utover for å inkludere tilstøtende intervaller med prisnivået 'Svært dyrt'. Dette fanger opp ekstremt dyre intervaller ved kantene av oppdagede perioder.",
"peak_price_max_extension_intervals": "Maksimalt antall ekstra intervaller per side (venstre og høyre kant). Hvert intervall er 15 minutter. Eksempel: 4 intervaller = opptil 1 times utvidelse per kant. Standard: 4",
"peak_price_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"
"peak_price_geometric_flex": "Ekstra fleksprosent for intervaller som faller innenfor en oppdaget pristopp (Λ-form). Når et topp-mønster oppdages for dagen, får intervaller innen toppsonene denne ekstra toleransen, slik at periodevarslingssystemet er mer tilbøyelig til å inkludere dem. 0 = deaktivert. Standard: 0"
}
}
},

View file

@ -245,16 +245,12 @@
"data": {
"best_price_extend_to_very_cheap": "Uitbreiden met zeer goedkope intervallen",
"best_price_max_extension_intervals": "Maximale uitbreidingsintervallen",
"best_price_geometric_flex": "Geometrische Flex Bonus",
"best_price_segment_forcing": "W-vorm segmentverstrekking",
"best_price_segment_min_periods": "Periodes per segment"
"best_price_geometric_flex": "Geometrische Flex Bonus"
},
"data_description": {
"best_price_extend_to_very_cheap": "Indien ingeschakeld, breiden gedetecteerde beste-prijsperioden zich uit aan de randen om aangrenzende intervallen met prijsniveau 'Zeer goedkoop' op te nemen. Dit vangt extreem goedkope intervallen op aan de randen van gedetecteerde perioden.",
"best_price_max_extension_intervals": "Maximaal aantal extra intervallen per kant (linker en rechter rand). Elk interval is 15 minuten. Voorbeeld: 4 intervallen = maximaal 1 uur uitbreiding per rand. Standaard: 4",
"best_price_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"
"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"
}
}
},
@ -310,16 +306,12 @@
"data": {
"peak_price_extend_to_very_expensive": "Uitbreiden met zeer dure intervallen",
"peak_price_max_extension_intervals": "Maximale uitbreidingsintervallen",
"peak_price_geometric_flex": "Geometrische Flex Bonus",
"peak_price_segment_forcing": "M-vorm segmentverstrekking",
"peak_price_segment_min_periods": "Periodes per segment"
"peak_price_geometric_flex": "Geometrische Flex Bonus"
},
"data_description": {
"peak_price_extend_to_very_expensive": "Indien ingeschakeld, breiden gedetecteerde piekprijsperioden zich uit aan de randen om aangrenzende intervallen met prijsniveau 'Zeer duur' op te nemen. Dit vangt extreem dure intervallen op aan de randen van gedetecteerde perioden.",
"peak_price_max_extension_intervals": "Maximaal aantal extra intervallen per kant (linker en rechter rand). Elk interval is 15 minuten. Voorbeeld: 4 intervallen = maximaal 1 uur uitbreiding per rand. Standaard: 4",
"peak_price_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"
"peak_price_geometric_flex": "Extra flex percentage voor intervallen die binnen een gedetecteerde prijspiek (Λ-vorm) vallen. Wanneer een piek-patroon wordt gedetecteerd voor de dag, krijgen intervallen binnen de piekzone deze extra tolerantie, waardoor de periodedetector ze eerder opneemt. 0 = uitgeschakeld. Standaard: 0"
}
}
},

View file

@ -245,16 +245,12 @@
"data": {
"best_price_extend_to_very_cheap": "Utvidga till mycket billiga intervall",
"best_price_max_extension_intervals": "Maximalt antal utvidgningsintervall",
"best_price_geometric_flex": "Geometrisk flexbonus",
"best_price_segment_forcing": "W-form segmenttvingning",
"best_price_segment_min_periods": "Perioder per segment"
"best_price_geometric_flex": "Geometrisk flexbonus"
},
"data_description": {
"best_price_extend_to_very_cheap": "När aktiverat utvidgas hittade bästa-prisperioder utåt för att inkludera angränsande intervall med prisnivån 'Mycket billig'. Detta fångar upp extremt billiga intervall vid kanterna av hittade perioder.",
"best_price_max_extension_intervals": "Maximalt antal extra intervall per sida (vänster och höger kant). Varje intervall är 15 minuter. Exempel: 4 intervall = upp till 1 timmes utvidgning per kant. Standard: 4",
"best_price_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"
"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"
}
}
},
@ -310,16 +306,12 @@
"data": {
"peak_price_extend_to_very_expensive": "Utvidga till mycket dyra intervall",
"peak_price_max_extension_intervals": "Maximalt antal utvidgningsintervall",
"peak_price_geometric_flex": "Geometrisk flexbonus",
"peak_price_segment_forcing": "M-form segmenttvingning",
"peak_price_segment_min_periods": "Perioder per segment"
"peak_price_geometric_flex": "Geometrisk flexbonus"
},
"data_description": {
"peak_price_extend_to_very_expensive": "När aktiverat utvidgas hittade topprisperioder utåt för att inkludera angränsande intervall med prisnivån 'Mycket dyr'. Detta fångar upp extremt dyra intervall vid kanterna av hittade perioder.",
"peak_price_max_extension_intervals": "Maximalt antal extra intervall per sida (vänster och höger kant). Varje intervall är 15 minuter. Exempel: 4 intervall = upp till 1 timmes utvidgning per kant. Standard: 4",
"peak_price_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"
"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"
}
}
},

View file

@ -834,7 +834,8 @@ INFO: Day 2025-11-11: Baseline satisfied (1 period, effective minimum is 1)
**Sensor Attributes:**
```yaml
min_periods_configured: 2 # User's setting
flat_days_detected: 1 # Explains why only 1 period found
periods_found_total: 1 # Actual result
flat_days_detected: 1 # Explains the difference
```
**Why not for Peak Price?**
@ -956,6 +957,7 @@ When debugging period calculation issues:
| Attribute | Type | When shown | Meaning |
|---|---|---|---|
| `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 |
| `relaxation_incomplete` | bool | Only when true | Relaxation exhausted, target not reached |
| `relaxation_active` | bool | Only when true | This specific period needed relaxed filters |

View file

@ -137,7 +137,7 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
### 7. Redundant/Derived Data
**Attributes:** `price_spread`, `volatility`, `diff_%`, `rating_difference_%`, `period_price_diff_from_daily_min`, `period_price_diff_from_daily_min_%`, `period_count_total`, `periods_remaining`
**Attributes:** `price_spread`, `volatility`, `diff_%`, `rating_difference_%`, `period_price_diff_from_daily_min`, `period_price_diff_from_daily_min_%`, `periods_total`, `periods_remaining`
**Reason:**
- Can be calculated from other attributes
@ -146,7 +146,7 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
**Impact:** ~100-200 bytes saved per state change
**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).
**Example:** `price_spread = price_max - price_min` (both are recorded, so spread can be calculated)
## Attributes That ARE Recorded
@ -166,8 +166,6 @@ These attributes **remain in history** because they provide essential analytical
### Period Data
- `start`, `end`, `duration_minutes` - Core period timing
- `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
- `relaxation_active` - Whether relaxation was used (boolean, useful for analyzing when periods needed relaxation)

View file

@ -523,7 +523,7 @@ This is **expected behavior** on days with very uniform electricity prices. When
```yaml
min_periods_configured: 2
period_count_today: 1
periods_found_total: 1
flat_days_detected: 1 # Uniform prices today → 1 period is the right answer
```
@ -656,8 +656,7 @@ relaxation_level: "price_diff_18.0%+level_any" # Found at 18% flex, level filte
# Calculation summary (always shown diagnostic overview of this calculation run):
min_periods_configured: 2 # What you configured as target
period_count_today: 2 # How many periods are scheduled today
period_count_tomorrow: 2 # How many periods are scheduled tomorrow (when data available)
periods_found_total: 3 # What was actually found across all days
# Optional (only shown when relevant):
period_interval_smoothed_count: 2 # Number of price spikes smoothed
@ -670,7 +669,7 @@ relaxation_incomplete: true # Some days couldn't reach the configured ta
#### What the diagnostic attributes mean
**`min_periods_configured` / `period_count_today`**
**`min_periods_configured` / `periods_found_total`**
These two values together quickly show whether the calculation achieved its goal:
@ -679,18 +678,18 @@ These two values together quickly show whether the calculation achieved its goal
```yaml
min_periods_configured: 2 # You asked for 2 periods per day
period_count_today: 2 # ✅ Today: target reached
period_count_tomorrow: 2 # ✅ Tomorrow: target reached
periods_found_total: 6 # 3 days × 2 periods = fully satisfied ✅
```
```yaml
min_periods_configured: 2
period_count_today: 1 # ⚠️ Today: only 1 period found
period_count_tomorrow: 2 # ✅ Tomorrow: target reached
periods_found_total: 5 # 3 days, but one day got only 1 period
```
</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`**
This is the most important diagnostic for days with very uniform prices (e.g. sunny spring/summer days with high solar generation):
@ -700,7 +699,7 @@ This is the most important diagnostic for days with very uniform prices (e.g. su
```yaml
min_periods_configured: 2
period_count_today: 1
periods_found_total: 1
flat_days_detected: 1 # ← This explains why you got 1 instead of 2
```
@ -719,7 +718,7 @@ This flag appears when even after all relaxation attempts, at least one day coul
```yaml
min_periods_configured: 2
period_count_today: 1
periods_found_total: 1
relaxation_incomplete: true # ← Relaxation tried everything, still short
```

View file

@ -130,7 +130,7 @@ Check the period sensor attributes to understand what happened:
relaxation_active: true # This day needed relaxation
relaxation_level: "price_diff_18.0%+level_any" # Found at 18% flex, level filter removed
min_periods_configured: 2 # Your target
period_count_today: 3 # What was actually found today
periods_found_total: 3 # What was actually found
```
</details>