From adf85792d5cd1b584be8e6eb81109fcf5f5a7ab0 Mon Sep 17 00:00:00 2001 From: Julian Pawlowski Date: Sun, 12 Apr 2026 16:30:19 +0000 Subject: [PATCH] refactor(shape_extension): improve period extension logic and documentation Refactor the period extension logic to clarify the handling of primary and fallback price levels. Update the documentation to reflect the changes in how periods extend into adjacent intervals. Impact: Users will benefit from clearer price extension behavior and improved performance in period calculations. --- .../period_handlers/shape_extension.py | 126 +++++++++++++----- 1 file changed, 93 insertions(+), 33 deletions(-) diff --git a/custom_components/tibber_prices/coordinator/period_handlers/shape_extension.py b/custom_components/tibber_prices/coordinator/period_handlers/shape_extension.py index b15c09b..795ecee 100644 --- a/custom_components/tibber_prices/coordinator/period_handlers/shape_extension.py +++ b/custom_components/tibber_prices/coordinator/period_handlers/shape_extension.py @@ -1,12 +1,17 @@ """ -Shape-based period extension: extend periods into adjacent VERY_CHEAP/VERY_EXPENSIVE intervals. +Shape-based period extension: extend periods into adjacent cheap/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: +each period's boundaries to include any directly-adjacent intervals that carry a +favourable 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 +- Best price periods → extend into VERY_CHEAP neighbours; fall back to CHEAP + on each side where no VERY_CHEAP neighbour exists. +- Peak price periods → extend into VERY_EXPENSIVE neighbours; fall back to + EXPENSIVE on each side where no VERY_EXPENSIVE exists. + +The fallback is evaluated **per side independently**: one side may extend via +VERY_CHEAP while the other side falls back to CHEAP. 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 @@ -20,6 +25,8 @@ from datetime import timedelta from typing import TYPE_CHECKING, Any from custom_components.tibber_prices.const import ( + PRICE_LEVEL_CHEAP, + PRICE_LEVEL_EXPENSIVE, PRICE_LEVEL_VERY_CHEAP, PRICE_LEVEL_VERY_EXPENSIVE, ) @@ -55,13 +62,17 @@ def extend_periods_for_shape( # noqa: PLR0913 - Extension requires all context time: TibberPricesTimeService, ) -> list[dict[str, Any]]: """ - Extend each period into adjacent VERY_CHEAP or VERY_EXPENSIVE intervals. + Extend each period into adjacent cheap/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. + For best price periods (reverse_sort=False): + Primary: extend into VERY_CHEAP neighbours. + Fallback: extend into CHEAP neighbours (per side, only if no VERY_CHEAP found). + For peak price periods (reverse_sort=True): + Primary: extend into VERY_EXPENSIVE neighbours. + Fallback: extend into EXPENSIVE neighbours (per side, only if no VERY_EXPENSIVE found). Only intervals that are directly contiguous with the period and carry the - target level are added. At most *max_extension_intervals* are consumed on + required level are added. At most *max_extension_intervals* are consumed on each side independently. Period statistics are fully recalculated after any extension. @@ -82,7 +93,12 @@ def extend_periods_for_shape( # noqa: PLR0913 - Extension requires all context if not periods or max_extension_intervals <= 0: return periods - target_level = PRICE_LEVEL_VERY_EXPENSIVE if reverse_sort else PRICE_LEVEL_VERY_CHEAP + if reverse_sort: + primary_level = PRICE_LEVEL_VERY_EXPENSIVE + fallback_level = PRICE_LEVEL_EXPENSIVE + else: + primary_level = PRICE_LEVEL_VERY_CHEAP + fallback_level = PRICE_LEVEL_CHEAP # Build a lookup dict: local datetime → full interval dict interval_index: dict[datetime, dict[str, Any]] = {} @@ -95,7 +111,8 @@ def extend_periods_for_shape( # noqa: PLR0913 - Extension requires all context _extend_period_edges( period, interval_index, - target_level=target_level, + primary_level=primary_level, + fallback_level=fallback_level, max_intervals=max_extension_intervals, thresholds=thresholds, price_context=price_context, @@ -107,25 +124,72 @@ def extend_periods_for_shape( # noqa: PLR0913 - Extension requires all context # ── private helpers ──────────────────────────────────────────────────────────── -def _extend_period_edges( # noqa: PLR0913, PLR0912, PLR0915 - Period edge extension requires many args, branches, and statements +def _walk_contiguous( + interval_index: dict[datetime, dict[str, Any]], + start_cursor: datetime, + step: timedelta, + target_level: str, + max_intervals: int, +) -> list[dict[str, Any]]: + """ + Walk contiguously from *start_cursor* in direction *step*, collecting intervals. + + Stops when the next interval is missing from the index, does not carry + *target_level*, or the *max_intervals* cap is reached. + + Args: + interval_index: Lookup map of ``{starts_at_datetime: interval_dict}``. + start_cursor: First position to check (already offset from the period edge). + step: ``+_INTERVAL_DURATION`` for rightward, ``-_INTERVAL_DURATION`` for leftward. + target_level: Required ``level`` value (e.g. ``"VERY_CHEAP"``). + max_intervals: Maximum intervals to collect. + + Returns: + Collected intervals in chronological order (reversed for leftward walks). + + """ + additions: list[dict[str, Any]] = [] + cursor = start_cursor + for _ in range(max_intervals): + iv = interval_index.get(cursor) + if iv is None or iv.get("level") != target_level: + break + additions.append(iv) + cursor += step + + # For leftward walks the list was built newest-first; reverse to chronological + if step < timedelta(0): + additions.reverse() + + return additions + + +def _extend_period_edges( # noqa: PLR0913 - Period edge extension requires many args period: dict[str, Any], interval_index: dict[datetime, dict[str, Any]], *, - target_level: str, + primary_level: str, + fallback_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. + Consume adjacent intervals on both edges of a period. + + Each side is evaluated independently: + 1. Try extending into *primary_level* neighbours (VERY_CHEAP / VERY_EXPENSIVE). + 2. If no primary-level neighbours were found on that side, fall back to + *fallback_level* neighbours (CHEAP / EXPENSIVE). The original period dict is never mutated; a new dict is returned. - If no extension is possible, the original dict is returned unchanged. + If no extension is possible on either side, the original dict is returned. 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"``. + primary_level: Preferred level (``"VERY_CHEAP"`` or ``"VERY_EXPENSIVE"``). + fallback_level: Fallback level (``"CHEAP"`` or ``"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. @@ -139,25 +203,21 @@ def _extend_period_edges( # noqa: PLR0913, PLR0912, PLR0915 - Period edge exten # ``end`` is the exclusive boundary: the last included interval starts at # ``end - _INTERVAL_DURATION``. + backward_step = -_INTERVAL_DURATION + forward_step = _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 + left_cursor = start - _INTERVAL_DURATION + left_additions = _walk_contiguous(interval_index, left_cursor, backward_step, primary_level, max_intervals) + if not left_additions: + # Fallback: no primary-level neighbours on this side → try fallback level + left_additions = _walk_contiguous(interval_index, left_cursor, backward_step, fallback_level, max_intervals) # ── 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 + right_additions = _walk_contiguous(interval_index, end, forward_step, primary_level, max_intervals) + if not right_additions: + # Fallback: no primary-level neighbours on this side → try fallback level + right_additions = _walk_contiguous(interval_index, end, forward_step, fallback_level, max_intervals) total_added = len(left_additions) + len(right_additions) if total_added == 0: @@ -199,7 +259,7 @@ def _extend_period_edges( # noqa: PLR0913, PLR0912, PLR0915 - Period edge exten 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 + reverse_sort = primary_level == PRICE_LEVEL_VERY_EXPENSIVE updated: dict[str, Any] = { **period, # Time fields