mirror of
https://github.com/jpawlowski/hass.tibber_prices.git
synced 2026-05-28 18:43:40 +00:00
feat(best_price,peak_price): add optional extension to VERY_CHEAP/VERY_EXPENSIVE intervals
After period detection, optionally walk left/right from each period boundary to absorb adjacent VERY_CHEAP (best price) or VERY_EXPENSIVE (peak price) intervals (step 7.5 in the pipeline). New constants: CONF_BEST_PRICE_EXTEND_TO_VERY_CHEAP, CONF_BEST_PRICE_MAX_EXTENSION_INTERVALS, CONF_PEAK_PRICE_EXTEND_TO_VERY_EXPENSIVE, CONF_PEAK_PRICE_MAX_EXTENSION_INTERVALS. Defaults: off / 4 intervals (1 hour per side). Hard maximum: 12 intervals (3 hours). Config stored under "extension_settings" section, reflected in period hash for correct cache invalidation. New module: coordinator/period_handlers/shape_extension.py handles the boundary walk, stat recalculation, and extension_intervals_added bookkeeping. Impact: Users can opt-in to wider best/peak price windows that include extreme-level adjacent intervals, reducing missed very cheap/expensive slots at period edges.
This commit is contained in:
parent
447dc907e6
commit
b7f1efce1f
12 changed files with 10006 additions and 9485 deletions
|
|
@ -12,7 +12,9 @@ import voluptuous as vol
|
||||||
from custom_components.tibber_prices.const import (
|
from custom_components.tibber_prices.const import (
|
||||||
BEST_PRICE_MAX_LEVEL_OPTIONS,
|
BEST_PRICE_MAX_LEVEL_OPTIONS,
|
||||||
CONF_AVERAGE_SENSOR_DISPLAY,
|
CONF_AVERAGE_SENSOR_DISPLAY,
|
||||||
|
CONF_BEST_PRICE_EXTEND_TO_VERY_CHEAP,
|
||||||
CONF_BEST_PRICE_FLEX,
|
CONF_BEST_PRICE_FLEX,
|
||||||
|
CONF_BEST_PRICE_MAX_EXTENSION_INTERVALS,
|
||||||
CONF_BEST_PRICE_MAX_LEVEL,
|
CONF_BEST_PRICE_MAX_LEVEL,
|
||||||
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,
|
||||||
|
|
@ -23,7 +25,9 @@ from custom_components.tibber_prices.const import (
|
||||||
CONF_EXTENDED_DESCRIPTIONS,
|
CONF_EXTENDED_DESCRIPTIONS,
|
||||||
CONF_MIN_PERIODS_BEST,
|
CONF_MIN_PERIODS_BEST,
|
||||||
CONF_MIN_PERIODS_PEAK,
|
CONF_MIN_PERIODS_PEAK,
|
||||||
|
CONF_PEAK_PRICE_EXTEND_TO_VERY_EXPENSIVE,
|
||||||
CONF_PEAK_PRICE_FLEX,
|
CONF_PEAK_PRICE_FLEX,
|
||||||
|
CONF_PEAK_PRICE_MAX_EXTENSION_INTERVALS,
|
||||||
CONF_PEAK_PRICE_MAX_LEVEL_GAP_COUNT,
|
CONF_PEAK_PRICE_MAX_LEVEL_GAP_COUNT,
|
||||||
CONF_PEAK_PRICE_MIN_DISTANCE_FROM_AVG,
|
CONF_PEAK_PRICE_MIN_DISTANCE_FROM_AVG,
|
||||||
CONF_PEAK_PRICE_MIN_LEVEL,
|
CONF_PEAK_PRICE_MIN_LEVEL,
|
||||||
|
|
@ -49,7 +53,9 @@ from custom_components.tibber_prices.const import (
|
||||||
CONF_VOLATILITY_THRESHOLD_MODERATE,
|
CONF_VOLATILITY_THRESHOLD_MODERATE,
|
||||||
CONF_VOLATILITY_THRESHOLD_VERY_HIGH,
|
CONF_VOLATILITY_THRESHOLD_VERY_HIGH,
|
||||||
DEFAULT_AVERAGE_SENSOR_DISPLAY,
|
DEFAULT_AVERAGE_SENSOR_DISPLAY,
|
||||||
|
DEFAULT_BEST_PRICE_EXTEND_TO_VERY_CHEAP,
|
||||||
DEFAULT_BEST_PRICE_FLEX,
|
DEFAULT_BEST_PRICE_FLEX,
|
||||||
|
DEFAULT_BEST_PRICE_MAX_EXTENSION_INTERVALS,
|
||||||
DEFAULT_BEST_PRICE_MAX_LEVEL,
|
DEFAULT_BEST_PRICE_MAX_LEVEL,
|
||||||
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,
|
||||||
|
|
@ -59,7 +65,9 @@ from custom_components.tibber_prices.const import (
|
||||||
DEFAULT_EXTENDED_DESCRIPTIONS,
|
DEFAULT_EXTENDED_DESCRIPTIONS,
|
||||||
DEFAULT_MIN_PERIODS_BEST,
|
DEFAULT_MIN_PERIODS_BEST,
|
||||||
DEFAULT_MIN_PERIODS_PEAK,
|
DEFAULT_MIN_PERIODS_PEAK,
|
||||||
|
DEFAULT_PEAK_PRICE_EXTEND_TO_VERY_EXPENSIVE,
|
||||||
DEFAULT_PEAK_PRICE_FLEX,
|
DEFAULT_PEAK_PRICE_FLEX,
|
||||||
|
DEFAULT_PEAK_PRICE_MAX_EXTENSION_INTERVALS,
|
||||||
DEFAULT_PEAK_PRICE_MAX_LEVEL_GAP_COUNT,
|
DEFAULT_PEAK_PRICE_MAX_LEVEL_GAP_COUNT,
|
||||||
DEFAULT_PEAK_PRICE_MIN_DISTANCE_FROM_AVG,
|
DEFAULT_PEAK_PRICE_MIN_DISTANCE_FROM_AVG,
|
||||||
DEFAULT_PEAK_PRICE_MIN_LEVEL,
|
DEFAULT_PEAK_PRICE_MIN_LEVEL,
|
||||||
|
|
@ -86,6 +94,7 @@ from custom_components.tibber_prices.const import (
|
||||||
DEFAULT_VOLATILITY_THRESHOLD_VERY_HIGH,
|
DEFAULT_VOLATILITY_THRESHOLD_VERY_HIGH,
|
||||||
DISPLAY_MODE_BASE,
|
DISPLAY_MODE_BASE,
|
||||||
DISPLAY_MODE_SUBUNIT,
|
DISPLAY_MODE_SUBUNIT,
|
||||||
|
MAX_EXTENSION_INTERVALS,
|
||||||
MAX_GAP_COUNT,
|
MAX_GAP_COUNT,
|
||||||
MAX_MIN_PERIOD_LENGTH,
|
MAX_MIN_PERIOD_LENGTH,
|
||||||
MAX_MIN_PERIODS,
|
MAX_MIN_PERIODS,
|
||||||
|
|
@ -618,6 +627,7 @@ def get_best_price_schema(
|
||||||
period_settings = options.get("period_settings", {})
|
period_settings = options.get("period_settings", {})
|
||||||
flexibility_settings = options.get("flexibility_settings", {})
|
flexibility_settings = options.get("flexibility_settings", {})
|
||||||
relaxation_settings = options.get("relaxation_and_target_periods", {})
|
relaxation_settings = options.get("relaxation_and_target_periods", {})
|
||||||
|
extension_settings = options.get("extension_settings", {})
|
||||||
|
|
||||||
# Get current values for override display
|
# Get current values for override display
|
||||||
min_period_length = int(
|
min_period_length = int(
|
||||||
|
|
@ -633,6 +643,12 @@ def get_best_price_schema(
|
||||||
enable_min_periods = relaxation_settings.get(CONF_ENABLE_MIN_PERIODS_BEST, DEFAULT_ENABLE_MIN_PERIODS_BEST)
|
enable_min_periods = relaxation_settings.get(CONF_ENABLE_MIN_PERIODS_BEST, DEFAULT_ENABLE_MIN_PERIODS_BEST)
|
||||||
min_periods = int(relaxation_settings.get(CONF_MIN_PERIODS_BEST, DEFAULT_MIN_PERIODS_BEST))
|
min_periods = int(relaxation_settings.get(CONF_MIN_PERIODS_BEST, DEFAULT_MIN_PERIODS_BEST))
|
||||||
relaxation_attempts = int(relaxation_settings.get(CONF_RELAXATION_ATTEMPTS_BEST, DEFAULT_RELAXATION_ATTEMPTS_BEST))
|
relaxation_attempts = int(relaxation_settings.get(CONF_RELAXATION_ATTEMPTS_BEST, DEFAULT_RELAXATION_ATTEMPTS_BEST))
|
||||||
|
extend_to_very_cheap = bool(
|
||||||
|
extension_settings.get(CONF_BEST_PRICE_EXTEND_TO_VERY_CHEAP, DEFAULT_BEST_PRICE_EXTEND_TO_VERY_CHEAP)
|
||||||
|
)
|
||||||
|
max_extension_intervals_best = int(
|
||||||
|
extension_settings.get(CONF_BEST_PRICE_MAX_EXTENSION_INTERVALS, DEFAULT_BEST_PRICE_MAX_EXTENSION_INTERVALS)
|
||||||
|
)
|
||||||
|
|
||||||
# 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 {}
|
||||||
|
|
@ -754,6 +770,28 @@ def get_best_price_schema(
|
||||||
vol.Schema(relaxation_fields),
|
vol.Schema(relaxation_fields),
|
||||||
{"collapsed": True},
|
{"collapsed": True},
|
||||||
),
|
),
|
||||||
|
vol.Required("extension_settings"): section(
|
||||||
|
vol.Schema(
|
||||||
|
{
|
||||||
|
vol.Optional(
|
||||||
|
CONF_BEST_PRICE_EXTEND_TO_VERY_CHEAP,
|
||||||
|
default=extend_to_very_cheap,
|
||||||
|
): BooleanSelector(selector.BooleanSelectorConfig()),
|
||||||
|
vol.Optional(
|
||||||
|
CONF_BEST_PRICE_MAX_EXTENSION_INTERVALS,
|
||||||
|
default=max_extension_intervals_best,
|
||||||
|
): NumberSelector(
|
||||||
|
NumberSelectorConfig(
|
||||||
|
min=1,
|
||||||
|
max=MAX_EXTENSION_INTERVALS,
|
||||||
|
step=1,
|
||||||
|
mode=NumberSelectorMode.SLIDER,
|
||||||
|
)
|
||||||
|
),
|
||||||
|
}
|
||||||
|
),
|
||||||
|
{"collapsed": True},
|
||||||
|
),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -779,6 +817,7 @@ def get_peak_price_schema(
|
||||||
period_settings = options.get("period_settings", {})
|
period_settings = options.get("period_settings", {})
|
||||||
flexibility_settings = options.get("flexibility_settings", {})
|
flexibility_settings = options.get("flexibility_settings", {})
|
||||||
relaxation_settings = options.get("relaxation_and_target_periods", {})
|
relaxation_settings = options.get("relaxation_and_target_periods", {})
|
||||||
|
extension_settings = options.get("extension_settings", {})
|
||||||
|
|
||||||
# Get current values for override display
|
# Get current values for override display
|
||||||
min_period_length = int(
|
min_period_length = int(
|
||||||
|
|
@ -794,6 +833,12 @@ def get_peak_price_schema(
|
||||||
enable_min_periods = relaxation_settings.get(CONF_ENABLE_MIN_PERIODS_PEAK, DEFAULT_ENABLE_MIN_PERIODS_PEAK)
|
enable_min_periods = relaxation_settings.get(CONF_ENABLE_MIN_PERIODS_PEAK, DEFAULT_ENABLE_MIN_PERIODS_PEAK)
|
||||||
min_periods = int(relaxation_settings.get(CONF_MIN_PERIODS_PEAK, DEFAULT_MIN_PERIODS_PEAK))
|
min_periods = int(relaxation_settings.get(CONF_MIN_PERIODS_PEAK, DEFAULT_MIN_PERIODS_PEAK))
|
||||||
relaxation_attempts = int(relaxation_settings.get(CONF_RELAXATION_ATTEMPTS_PEAK, DEFAULT_RELAXATION_ATTEMPTS_PEAK))
|
relaxation_attempts = int(relaxation_settings.get(CONF_RELAXATION_ATTEMPTS_PEAK, DEFAULT_RELAXATION_ATTEMPTS_PEAK))
|
||||||
|
extend_to_very_expensive = bool(
|
||||||
|
extension_settings.get(CONF_PEAK_PRICE_EXTEND_TO_VERY_EXPENSIVE, DEFAULT_PEAK_PRICE_EXTEND_TO_VERY_EXPENSIVE)
|
||||||
|
)
|
||||||
|
max_extension_intervals_peak = int(
|
||||||
|
extension_settings.get(CONF_PEAK_PRICE_MAX_EXTENSION_INTERVALS, DEFAULT_PEAK_PRICE_MAX_EXTENSION_INTERVALS)
|
||||||
|
)
|
||||||
|
|
||||||
# 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 {}
|
||||||
|
|
@ -915,6 +960,28 @@ def get_peak_price_schema(
|
||||||
vol.Schema(relaxation_fields),
|
vol.Schema(relaxation_fields),
|
||||||
{"collapsed": True},
|
{"collapsed": True},
|
||||||
),
|
),
|
||||||
|
vol.Required("extension_settings"): section(
|
||||||
|
vol.Schema(
|
||||||
|
{
|
||||||
|
vol.Optional(
|
||||||
|
CONF_PEAK_PRICE_EXTEND_TO_VERY_EXPENSIVE,
|
||||||
|
default=extend_to_very_expensive,
|
||||||
|
): BooleanSelector(selector.BooleanSelectorConfig()),
|
||||||
|
vol.Optional(
|
||||||
|
CONF_PEAK_PRICE_MAX_EXTENSION_INTERVALS,
|
||||||
|
default=max_extension_intervals_peak,
|
||||||
|
): NumberSelector(
|
||||||
|
NumberSelectorConfig(
|
||||||
|
min=1,
|
||||||
|
max=MAX_EXTENSION_INTERVALS,
|
||||||
|
step=1,
|
||||||
|
mode=NumberSelectorMode.SLIDER,
|
||||||
|
)
|
||||||
|
),
|
||||||
|
}
|
||||||
|
),
|
||||||
|
{"collapsed": True},
|
||||||
|
),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -68,6 +68,10 @@ CONF_RELAXATION_ATTEMPTS_BEST = "relaxation_attempts_best"
|
||||||
CONF_ENABLE_MIN_PERIODS_PEAK = "enable_min_periods_peak"
|
CONF_ENABLE_MIN_PERIODS_PEAK = "enable_min_periods_peak"
|
||||||
CONF_MIN_PERIODS_PEAK = "min_periods_peak"
|
CONF_MIN_PERIODS_PEAK = "min_periods_peak"
|
||||||
CONF_RELAXATION_ATTEMPTS_PEAK = "relaxation_attempts_peak"
|
CONF_RELAXATION_ATTEMPTS_PEAK = "relaxation_attempts_peak"
|
||||||
|
CONF_BEST_PRICE_EXTEND_TO_VERY_CHEAP = "best_price_extend_to_very_cheap"
|
||||||
|
CONF_BEST_PRICE_MAX_EXTENSION_INTERVALS = "best_price_max_extension_intervals"
|
||||||
|
CONF_PEAK_PRICE_EXTEND_TO_VERY_EXPENSIVE = "peak_price_extend_to_very_expensive"
|
||||||
|
CONF_PEAK_PRICE_MAX_EXTENSION_INTERVALS = "peak_price_max_extension_intervals"
|
||||||
|
|
||||||
ATTRIBUTION = "Data provided by Tibber"
|
ATTRIBUTION = "Data provided by Tibber"
|
||||||
|
|
||||||
|
|
@ -131,6 +135,10 @@ DEFAULT_RELAXATION_ATTEMPTS_BEST = 11 # Default: 11 steps allows escalation fro
|
||||||
DEFAULT_ENABLE_MIN_PERIODS_PEAK = True # Default: minimum periods feature enabled for peak price
|
DEFAULT_ENABLE_MIN_PERIODS_PEAK = True # Default: minimum periods feature enabled for peak price
|
||||||
DEFAULT_MIN_PERIODS_PEAK = 2 # Default: require at least 2 peak price periods (when enabled)
|
DEFAULT_MIN_PERIODS_PEAK = 2 # Default: require at least 2 peak price periods (when enabled)
|
||||||
DEFAULT_RELAXATION_ATTEMPTS_PEAK = 11 # Default: 11 steps allows escalation from 20% to 50% (3% increment per step)
|
DEFAULT_RELAXATION_ATTEMPTS_PEAK = 11 # Default: 11 steps allows escalation from 20% to 50% (3% increment per step)
|
||||||
|
DEFAULT_BEST_PRICE_EXTEND_TO_VERY_CHEAP = False # Default: disabled (opt-in feature)
|
||||||
|
DEFAULT_BEST_PRICE_MAX_EXTENSION_INTERVALS = 4 # Default: up to 4 intervals (1 hour) per side
|
||||||
|
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
|
||||||
|
|
||||||
# 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
|
||||||
|
|
@ -139,6 +147,7 @@ MAX_DISTANCE_PERCENTAGE = 50 # Maximum distance from average percentage (GUI sl
|
||||||
MAX_GAP_COUNT = 8 # Maximum gap count for level filtering (GUI slider limit)
|
MAX_GAP_COUNT = 8 # Maximum gap count for level filtering (GUI slider limit)
|
||||||
MAX_MIN_PERIODS = 10 # Maximum number of minimum periods per day (GUI slider limit)
|
MAX_MIN_PERIODS = 10 # Maximum number of minimum periods per day (GUI slider limit)
|
||||||
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)
|
||||||
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)
|
||||||
|
|
||||||
|
|
@ -407,6 +416,13 @@ def get_default_options(currency_code: str | None) -> dict[str, Any]:
|
||||||
CONF_MIN_PERIODS_PEAK: DEFAULT_MIN_PERIODS_PEAK,
|
CONF_MIN_PERIODS_PEAK: DEFAULT_MIN_PERIODS_PEAK,
|
||||||
CONF_RELAXATION_ATTEMPTS_PEAK: DEFAULT_RELAXATION_ATTEMPTS_PEAK,
|
CONF_RELAXATION_ATTEMPTS_PEAK: DEFAULT_RELAXATION_ATTEMPTS_PEAK,
|
||||||
},
|
},
|
||||||
|
# Nested section: Extension settings (shared by best/peak price)
|
||||||
|
"extension_settings": {
|
||||||
|
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_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,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,9 @@ from .outlier_filtering import filter_price_outliers
|
||||||
# Re-export relaxation
|
# Re-export relaxation
|
||||||
from .relaxation import calculate_periods_with_relaxation
|
from .relaxation import calculate_periods_with_relaxation
|
||||||
|
|
||||||
|
# Re-export shape extension
|
||||||
|
from .shape_extension import extend_periods_for_shape
|
||||||
|
|
||||||
# Re-export constants and types
|
# Re-export constants and types
|
||||||
from .types import (
|
from .types import (
|
||||||
ALL_DAY_PATTERNS,
|
ALL_DAY_PATTERNS,
|
||||||
|
|
@ -80,5 +83,6 @@ __all__ = [
|
||||||
"calculate_periods",
|
"calculate_periods",
|
||||||
"calculate_periods_with_relaxation",
|
"calculate_periods_with_relaxation",
|
||||||
"detect_day_patterns",
|
"detect_day_patterns",
|
||||||
|
"extend_periods_for_shape",
|
||||||
"filter_price_outliers",
|
"filter_price_outliers",
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ from .period_building import (
|
||||||
from .period_statistics import (
|
from .period_statistics import (
|
||||||
extract_period_summaries,
|
extract_period_summaries,
|
||||||
)
|
)
|
||||||
|
from .shape_extension import extend_periods_for_shape
|
||||||
from .types import TibberPricesThresholdConfig
|
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)
|
||||||
|
|
@ -209,6 +210,20 @@ def calculate_periods(
|
||||||
time=time,
|
time=time,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Step 7.5: Extend periods into adjacent VERY_CHEAP / VERY_EXPENSIVE intervals
|
||||||
|
# This is an opt-in feature (disabled by default) that adds contiguous
|
||||||
|
# extreme-level intervals on each side of an already-found period.
|
||||||
|
if config.extend_to_extreme and config.max_extension_intervals > 0:
|
||||||
|
period_summaries = extend_periods_for_shape(
|
||||||
|
period_summaries,
|
||||||
|
all_prices_sorted,
|
||||||
|
price_context,
|
||||||
|
reverse_sort=reverse_sort,
|
||||||
|
max_extension_intervals=config.max_extension_intervals,
|
||||||
|
thresholds=thresholds,
|
||||||
|
time=time,
|
||||||
|
)
|
||||||
|
|
||||||
# Step 8: Cross-day extension for late-night periods
|
# Step 8: Cross-day extension for late-night periods
|
||||||
# If a best-price period ends near midnight and tomorrow has continued low prices,
|
# If a best-price period ends near midnight and tomorrow has continued low prices,
|
||||||
# extend the period across midnight to give users the full cheap window
|
# extend the period across midnight to give users the full cheap window
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,258 @@
|
||||||
|
"""
|
||||||
|
Shape-based period extension: extend periods into adjacent VERY_CHEAP/VERY_EXPENSIVE intervals.
|
||||||
|
|
||||||
|
After periods are identified by the core algorithm, this module optionally extends
|
||||||
|
each period's boundaries to include any directly-adjacent intervals that carry the
|
||||||
|
most extreme price level relevant to the period type:
|
||||||
|
|
||||||
|
- Best price periods → extend into VERY_CHEAP neighbouring intervals
|
||||||
|
- Peak price periods → extend into VERY_EXPENSIVE neighbouring intervals
|
||||||
|
|
||||||
|
Extension is purely additive and opt-in (disabled by default). It does not affect
|
||||||
|
the core period-finding logic; periods that would not normally be found are not
|
||||||
|
created by this step.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import statistics
|
||||||
|
from datetime import timedelta
|
||||||
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
|
from custom_components.tibber_prices.const import (
|
||||||
|
PRICE_LEVEL_VERY_CHEAP,
|
||||||
|
PRICE_LEVEL_VERY_EXPENSIVE,
|
||||||
|
)
|
||||||
|
from custom_components.tibber_prices.utils.price import (
|
||||||
|
aggregate_period_levels,
|
||||||
|
aggregate_period_ratings,
|
||||||
|
)
|
||||||
|
|
||||||
|
from .period_statistics import (
|
||||||
|
calculate_aggregated_rating_difference,
|
||||||
|
calculate_period_price_diff,
|
||||||
|
calculate_period_price_statistics,
|
||||||
|
)
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from custom_components.tibber_prices.coordinator.time_service import TibberPricesTimeService
|
||||||
|
|
||||||
|
from .types import TibberPricesThresholdConfig
|
||||||
|
|
||||||
|
_INTERVAL_DURATION = timedelta(minutes=15)
|
||||||
|
|
||||||
|
|
||||||
|
def extend_periods_for_shape( # noqa: PLR0913 - Extension requires all context params
|
||||||
|
periods: list[dict[str, Any]],
|
||||||
|
all_prices: list[dict[str, Any]],
|
||||||
|
price_context: dict[str, Any],
|
||||||
|
*,
|
||||||
|
reverse_sort: bool,
|
||||||
|
max_extension_intervals: int,
|
||||||
|
thresholds: TibberPricesThresholdConfig,
|
||||||
|
time: TibberPricesTimeService,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Extend each period into adjacent VERY_CHEAP or VERY_EXPENSIVE intervals.
|
||||||
|
|
||||||
|
For best price periods (reverse_sort=False): extend into VERY_CHEAP neighbours.
|
||||||
|
For peak price periods (reverse_sort=True): extend into VERY_EXPENSIVE neighbours.
|
||||||
|
|
||||||
|
Only intervals that are directly contiguous with the period and carry the
|
||||||
|
target level are added. At most *max_extension_intervals* are consumed on
|
||||||
|
each side independently. Period statistics are fully recalculated after
|
||||||
|
any extension.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
periods: Period summary dicts from ``extract_period_summaries``.
|
||||||
|
all_prices: All enriched price intervals (yesterday + today + tomorrow).
|
||||||
|
price_context: Dict with ``ref_prices`` and ``avg_prices`` per calendar day.
|
||||||
|
reverse_sort: ``True`` for peak price, ``False`` for best price.
|
||||||
|
max_extension_intervals: Maximum extra intervals that may be added per side.
|
||||||
|
thresholds: Threshold configuration for level / rating aggregation.
|
||||||
|
time: Time-service instance used to resolve ``startsAt`` timestamps.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Updated list of period dicts, potentially with extended boundaries and
|
||||||
|
recalculated statistics. Unmodified periods are returned as-is.
|
||||||
|
|
||||||
|
"""
|
||||||
|
if not periods or max_extension_intervals <= 0:
|
||||||
|
return periods
|
||||||
|
|
||||||
|
target_level = PRICE_LEVEL_VERY_EXPENSIVE if reverse_sort else PRICE_LEVEL_VERY_CHEAP
|
||||||
|
|
||||||
|
# Build a lookup dict: local datetime → full interval dict
|
||||||
|
interval_index: dict[datetime, dict[str, Any]] = {}
|
||||||
|
for iv in all_prices:
|
||||||
|
t = time.get_interval_time(iv)
|
||||||
|
if t is not None:
|
||||||
|
interval_index[t] = iv
|
||||||
|
|
||||||
|
return [
|
||||||
|
_extend_period_edges(
|
||||||
|
period,
|
||||||
|
interval_index,
|
||||||
|
target_level=target_level,
|
||||||
|
max_intervals=max_extension_intervals,
|
||||||
|
thresholds=thresholds,
|
||||||
|
price_context=price_context,
|
||||||
|
)
|
||||||
|
for period in periods
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# ── private helpers ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _extend_period_edges( # noqa: PLR0913, PLR0912, PLR0915 - Period edge extension requires many args, branches, and statements
|
||||||
|
period: dict[str, Any],
|
||||||
|
interval_index: dict[datetime, dict[str, Any]],
|
||||||
|
*,
|
||||||
|
target_level: str,
|
||||||
|
max_intervals: int,
|
||||||
|
thresholds: TibberPricesThresholdConfig,
|
||||||
|
price_context: dict[str, Any],
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Consume adjacent target-level intervals on both edges of a period.
|
||||||
|
|
||||||
|
The original period dict is never mutated; a new dict is returned.
|
||||||
|
If no extension is possible, the original dict is returned unchanged.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
period: Period summary dict with ``start`` and ``end`` datetime keys.
|
||||||
|
interval_index: Lookup map of ``{starts_at_datetime: interval_dict}``.
|
||||||
|
target_level: ``"VERY_CHEAP"`` or ``"VERY_EXPENSIVE"``.
|
||||||
|
max_intervals: Maximum intervals that may be added on each side.
|
||||||
|
thresholds: Threshold config for aggregation helpers.
|
||||||
|
price_context: Reference prices / averages per calendar day.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Extended (or original) period summary dict.
|
||||||
|
|
||||||
|
"""
|
||||||
|
start: datetime = period["start"]
|
||||||
|
end: datetime = period["end"]
|
||||||
|
# ``end`` is the exclusive boundary: the last included interval starts at
|
||||||
|
# ``end - _INTERVAL_DURATION``.
|
||||||
|
|
||||||
|
# ── walk LEFT (earlier than period start) ─────────────────────────────────
|
||||||
|
left_additions: list[dict[str, Any]] = []
|
||||||
|
cursor = start - _INTERVAL_DURATION
|
||||||
|
for _ in range(max_intervals):
|
||||||
|
iv = interval_index.get(cursor)
|
||||||
|
if iv is None or iv.get("level") != target_level:
|
||||||
|
break
|
||||||
|
left_additions.insert(0, iv)
|
||||||
|
cursor -= _INTERVAL_DURATION
|
||||||
|
|
||||||
|
# ── walk RIGHT (later than period end) ────────────────────────────────────
|
||||||
|
right_additions: list[dict[str, Any]] = []
|
||||||
|
cursor = end # first interval AFTER the period
|
||||||
|
for _ in range(max_intervals):
|
||||||
|
iv = interval_index.get(cursor)
|
||||||
|
if iv is None or iv.get("level") != target_level:
|
||||||
|
break
|
||||||
|
right_additions.append(iv)
|
||||||
|
cursor += _INTERVAL_DURATION
|
||||||
|
|
||||||
|
total_added = len(left_additions) + len(right_additions)
|
||||||
|
if total_added == 0:
|
||||||
|
return period
|
||||||
|
|
||||||
|
# ── rebuild full interval list for the extended period ────────────────────
|
||||||
|
original_intervals = _collect_original_intervals(start, end, interval_index)
|
||||||
|
all_period_intervals = left_additions + original_intervals + right_additions
|
||||||
|
|
||||||
|
# ── recalculate boundaries ────────────────────────────────────────────────
|
||||||
|
new_start = start - _INTERVAL_DURATION * len(left_additions)
|
||||||
|
new_end = end + _INTERVAL_DURATION * len(right_additions)
|
||||||
|
new_duration_minutes = int((new_end - new_start).total_seconds() // 60)
|
||||||
|
new_interval_count = len(all_period_intervals)
|
||||||
|
|
||||||
|
# ── recalculate price statistics ──────────────────────────────────────────
|
||||||
|
price_stats = calculate_period_price_statistics(all_period_intervals)
|
||||||
|
period_price_diff, period_price_diff_pct = calculate_period_price_diff(
|
||||||
|
price_stats["price_mean"], new_start, price_context
|
||||||
|
)
|
||||||
|
rating_diff_pct = calculate_aggregated_rating_difference(all_period_intervals)
|
||||||
|
|
||||||
|
# ── recalculate level / rating aggregates ─────────────────────────────────
|
||||||
|
new_level = aggregate_period_levels(all_period_intervals)
|
||||||
|
new_rating: str | None = None
|
||||||
|
if thresholds.threshold_low is not None and thresholds.threshold_high is not None:
|
||||||
|
new_rating, _ = aggregate_period_ratings(
|
||||||
|
all_period_intervals,
|
||||||
|
thresholds.threshold_low,
|
||||||
|
thresholds.threshold_high,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── recalculate volatility (coefficient of variation) ────────────────────
|
||||||
|
prices_for_vol = [float(p["total"]) for p in all_period_intervals if "total" in p]
|
||||||
|
cv_pct: float | None = None
|
||||||
|
if len(prices_for_vol) >= 2: # noqa: PLR2004
|
||||||
|
mean_p = statistics.mean(prices_for_vol)
|
||||||
|
if mean_p > 0:
|
||||||
|
cv_pct = round(statistics.stdev(prices_for_vol) / mean_p * 100, 1)
|
||||||
|
|
||||||
|
# ── assemble updated period dict (keep structural fields, update statistics) ─
|
||||||
|
reverse_sort = target_level == PRICE_LEVEL_VERY_EXPENSIVE
|
||||||
|
updated: dict[str, Any] = {
|
||||||
|
**period,
|
||||||
|
# Time fields
|
||||||
|
"start": new_start,
|
||||||
|
"end": new_end,
|
||||||
|
"duration_minutes": new_duration_minutes,
|
||||||
|
# Core decision attributes
|
||||||
|
"level": new_level,
|
||||||
|
"rating_level": new_rating,
|
||||||
|
"rating_difference_%": rating_diff_pct,
|
||||||
|
# Price statistics
|
||||||
|
"price_mean": price_stats["price_mean"],
|
||||||
|
"price_median": price_stats["price_median"],
|
||||||
|
"price_min": price_stats["price_min"],
|
||||||
|
"price_max": price_stats["price_max"],
|
||||||
|
"price_spread": price_stats["price_spread"],
|
||||||
|
"price_coefficient_variation_%": cv_pct,
|
||||||
|
# Detail
|
||||||
|
"period_interval_count": new_interval_count,
|
||||||
|
# Extension metadata
|
||||||
|
"extension_intervals_added": total_added,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Refresh period price diff (replaces old value from base period)
|
||||||
|
if reverse_sort:
|
||||||
|
updated.pop("period_price_diff_from_daily_min", None)
|
||||||
|
updated.pop("period_price_diff_from_daily_min_%", None)
|
||||||
|
if period_price_diff is not None:
|
||||||
|
updated["period_price_diff_from_daily_max"] = period_price_diff
|
||||||
|
if period_price_diff_pct is not None:
|
||||||
|
updated["period_price_diff_from_daily_max_%"] = period_price_diff_pct
|
||||||
|
else:
|
||||||
|
updated.pop("period_price_diff_from_daily_max", None)
|
||||||
|
updated.pop("period_price_diff_from_daily_max_%", None)
|
||||||
|
if period_price_diff is not None:
|
||||||
|
updated["period_price_diff_from_daily_min"] = period_price_diff
|
||||||
|
if period_price_diff_pct is not None:
|
||||||
|
updated["period_price_diff_from_daily_min_%"] = period_price_diff_pct
|
||||||
|
|
||||||
|
return updated
|
||||||
|
|
||||||
|
|
||||||
|
def _collect_original_intervals(
|
||||||
|
start: datetime,
|
||||||
|
end: datetime,
|
||||||
|
interval_index: dict[datetime, dict[str, Any]],
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""Reconstruct the ordered interval list for an existing period from the index."""
|
||||||
|
result: list[dict[str, Any]] = []
|
||||||
|
cursor = start
|
||||||
|
while cursor < end:
|
||||||
|
iv = interval_index.get(cursor)
|
||||||
|
if iv is not None:
|
||||||
|
result.append(iv)
|
||||||
|
cursor += _INTERVAL_DURATION
|
||||||
|
return result
|
||||||
|
|
@ -56,6 +56,8 @@ class TibberPricesPeriodConfig(NamedTuple):
|
||||||
threshold_volatility_very_high: float = DEFAULT_VOLATILITY_THRESHOLD_VERY_HIGH
|
threshold_volatility_very_high: float = DEFAULT_VOLATILITY_THRESHOLD_VERY_HIGH
|
||||||
level_filter: str | None = None # "any", "cheap", "expensive", etc. or None
|
level_filter: str | None = None # "any", "cheap", "expensive", etc. or None
|
||||||
gap_count: int = 0 # Number of allowed consecutive deviating intervals
|
gap_count: int = 0 # Number of allowed consecutive deviating 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)
|
||||||
|
|
||||||
|
|
||||||
class TibberPricesPeriodData(NamedTuple):
|
class TibberPricesPeriodData(NamedTuple):
|
||||||
|
|
|
||||||
|
|
@ -225,6 +225,41 @@ class TibberPricesPeriodCalculator:
|
||||||
"min_period_length": int(min_period_length),
|
"min_period_length": int(min_period_length),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Extension settings (stored in 'extension_settings' nested section)
|
||||||
|
if reverse_sort:
|
||||||
|
extend_to_extreme = bool(
|
||||||
|
self._get_option(
|
||||||
|
_const.CONF_PEAK_PRICE_EXTEND_TO_VERY_EXPENSIVE,
|
||||||
|
"extension_settings",
|
||||||
|
_const.DEFAULT_PEAK_PRICE_EXTEND_TO_VERY_EXPENSIVE,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
max_extension_intervals = int(
|
||||||
|
self._get_option(
|
||||||
|
_const.CONF_PEAK_PRICE_MAX_EXTENSION_INTERVALS,
|
||||||
|
"extension_settings",
|
||||||
|
_const.DEFAULT_PEAK_PRICE_MAX_EXTENSION_INTERVALS,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
extend_to_extreme = bool(
|
||||||
|
self._get_option(
|
||||||
|
_const.CONF_BEST_PRICE_EXTEND_TO_VERY_CHEAP,
|
||||||
|
"extension_settings",
|
||||||
|
_const.DEFAULT_BEST_PRICE_EXTEND_TO_VERY_CHEAP,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
max_extension_intervals = int(
|
||||||
|
self._get_option(
|
||||||
|
_const.CONF_BEST_PRICE_MAX_EXTENSION_INTERVALS,
|
||||||
|
"extension_settings",
|
||||||
|
_const.DEFAULT_BEST_PRICE_MAX_EXTENSION_INTERVALS,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
config["extend_to_extreme"] = extend_to_extreme
|
||||||
|
config["max_extension_intervals"] = max_extension_intervals
|
||||||
|
|
||||||
# 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
|
||||||
|
|
@ -702,6 +737,8 @@ class TibberPricesPeriodCalculator:
|
||||||
threshold_volatility_very_high=threshold_volatility_very_high,
|
threshold_volatility_very_high=threshold_volatility_very_high,
|
||||||
level_filter=max_level_best,
|
level_filter=max_level_best,
|
||||||
gap_count=gap_count_best,
|
gap_count=gap_count_best,
|
||||||
|
extend_to_extreme=best_config["extend_to_extreme"],
|
||||||
|
max_extension_intervals=best_config["max_extension_intervals"],
|
||||||
)
|
)
|
||||||
best_periods = calculate_periods_with_relaxation(
|
best_periods = calculate_periods_with_relaxation(
|
||||||
all_prices,
|
all_prices,
|
||||||
|
|
@ -783,6 +820,8 @@ class TibberPricesPeriodCalculator:
|
||||||
threshold_volatility_very_high=threshold_volatility_very_high,
|
threshold_volatility_very_high=threshold_volatility_very_high,
|
||||||
level_filter=min_level_peak,
|
level_filter=min_level_peak,
|
||||||
gap_count=gap_count_peak,
|
gap_count=gap_count_peak,
|
||||||
|
extend_to_extreme=peak_config["extend_to_extreme"],
|
||||||
|
max_extension_intervals=peak_config["max_extension_intervals"],
|
||||||
)
|
)
|
||||||
peak_periods = calculate_periods_with_relaxation(
|
peak_periods = calculate_periods_with_relaxation(
|
||||||
all_prices,
|
all_prices,
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
Loading…
Reference in a new issue