Compare commits

..

4 commits

Author SHA1 Message Date
Julian Pawlowski
75da094c81 refactor(day_patterns): rename double valley/peak to double dip/duck curve
Some checks are pending
Auto-Tag on Version Bump / Check and create version tag (push) Waiting to run
Lint / Ruff (push) Waiting to run
Validate / Hassfest validation (push) Waiting to run
Validate / HACS validation (push) Waiting to run
Updated pattern names for clarity and consistency in the codebase. The changes include renaming constants and updating related logic to reflect the new terminology.

Impact: Improved readability and understanding of day pattern classifications for developers.
2026-04-17 14:37:17 +00:00
Julian Pawlowski
ba3e127ac7 refactor(day_pattern): enhance pattern classification with price boundaries
Refactor the pattern classification logic to include start and end prices for better accuracy in identifying day patterns. This change improves the classification of price patterns, particularly for cases involving valleys and peaks.

Impact: Users will experience more accurate price pattern classifications, leading to better decision-making based on price trends.
2026-04-17 14:02:02 +00:00
Julian Pawlowski
2092d28ece chore(scripts): normalize release note footer across all backends
The Copilot backend relies on the AI to reproduce the footer text exactly,
which leads to subtle differences (URL parameter order, whitespace, emoji
encoding) compared to the git-cliff template in cliff.toml.

Fix: define CANONICAL_FOOTER as a single bash constant and pipe all three
backends (copilot, git-cliff, manual) through ensure_canonical_footer(),
which strips any existing BMAC footer block and appends the canonical one.

Also tell the AI not to generate a footer at all, since it is now added
programmatically. The manual backend gains a footer it previously lacked.

User-Impact: none
2026-04-17 12:41:55 +00:00
Julian Pawlowski
432eb6502c chore(release): bump version to 0.31.0b3 2026-04-17 12:23:04 +00:00
16 changed files with 374 additions and 119 deletions

View file

@ -34,8 +34,8 @@ from .shape_extension import extend_periods_for_shape
# Re-export constants and types
from .types import (
ALL_DAY_PATTERNS,
DAY_PATTERN_DOUBLE_PEAK,
DAY_PATTERN_DOUBLE_VALLEY,
DAY_PATTERN_DOUBLE_DIP,
DAY_PATTERN_DUCK_CURVE,
DAY_PATTERN_FALLING,
DAY_PATTERN_FLAT,
DAY_PATTERN_MIXED,
@ -59,8 +59,8 @@ from .types import (
__all__ = [
"ALL_DAY_PATTERNS",
"DAY_PATTERN_DOUBLE_PEAK",
"DAY_PATTERN_DOUBLE_VALLEY",
"DAY_PATTERN_DOUBLE_DIP",
"DAY_PATTERN_DUCK_CURVE",
"DAY_PATTERN_FALLING",
"DAY_PATTERN_FLAT",
"DAY_PATTERN_MIXED",

View file

@ -183,7 +183,7 @@ def calculate_periods(
)
# 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),
# For days detected as W-shape (DOUBLE_DIP for best) or M-shape (DUCK_CURVE 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(
@ -315,9 +315,9 @@ def _apply_segment_forcing(
"""
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
For DOUBLE_DIP 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
For DUCK_CURVE days (peak price): splits at the central price valley and
ensures each peak side has the required number of periods.
Args:
@ -335,12 +335,12 @@ def _apply_segment_forcing(
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
from .types import DAY_PATTERN_DOUBLE_DIP, DAY_PATTERN_DUCK_CURVE, INDENT_L1, INDENT_L2 # noqa: PLC0415
_LOGGER = logging.getLogger(__name__)
reverse_sort = config.reverse_sort
target_pattern = DAY_PATTERN_DOUBLE_PEAK if reverse_sort else DAY_PATTERN_DOUBLE_VALLEY
target_pattern = DAY_PATTERN_DUCK_CURVE if reverse_sort else DAY_PATTERN_DOUBLE_DIP
segment_min_periods = config.segment_min_periods
merged_periods = list(periods)
@ -362,8 +362,8 @@ def _apply_segment_forcing(
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
# DOUBLE_DIP → central peak = highest price between the two valleys
# DUCK_CURVE → central valley = lowest price between the two peaks
n = len(day_intervals)
middle = day_intervals[n // 4 : 3 * n // 4]
if not middle:

View file

@ -6,8 +6,8 @@ into a small set of patterns that are meaningful for switching decisions:
VALLEY - Single price minimum (U/V-shape, cheap middle)
PEAK - Single price maximum (Lambda-shape, expensive middle)
DOUBLE_VALLEY - Two minima separated by a peak (W-shape)
DOUBLE_PEAK - Two peaks separated by a valley (M-shape)
DOUBLE_DIP - Two minima separated by a peak (W-shape)
DUCK_CURVE - Two peaks with midday valley (M-shape, solar duck curve)
FLAT - No significant variation (CV <= 10 %)
RISING - Monotonically / persistently rising
FALLING - Monotonically / persistently falling
@ -63,8 +63,8 @@ _EDGE_ZONE = 0.25
# Pattern string constants
DAY_PATTERN_VALLEY = "valley"
DAY_PATTERN_PEAK = "peak"
DAY_PATTERN_DOUBLE_VALLEY = "double_valley"
DAY_PATTERN_DOUBLE_PEAK = "double_peak"
DAY_PATTERN_DOUBLE_DIP = "double_dip"
DAY_PATTERN_DUCK_CURVE = "duck_curve"
DAY_PATTERN_FLAT = "flat"
DAY_PATTERN_RISING = "rising"
DAY_PATTERN_FALLING = "falling"
@ -172,7 +172,13 @@ def _detect_single_day_pattern(
extrema = _find_significant_extrema(smoothed, min_amplitude=price_span * MIN_EXTREMUM_AMPLITUDE_RATIO)
# ── classify pattern ────────────────────────────────────────────────────────
pattern, confidence = _classify_pattern(extrema, cv_pct, times)
pattern, confidence = _classify_pattern(
extrema,
cv_pct,
times,
start_price=smoothed[0],
end_price=smoothed[-1],
)
# ── knee points + primary extreme time ─────────────────────────────────────
extreme_time: datetime | None = None
@ -196,18 +202,27 @@ def _detect_single_day_pattern(
peak_start = times[lk] if lk is not None and lk < len(times) else None
peak_end = times[rk] if rk is not None and rk < len(times) else None
elif pattern == DAY_PATTERN_DOUBLE_VALLEY and extrema:
elif pattern == DAY_PATTERN_DOUBLE_DIP and extrema:
# Primary extreme = deeper of the two minima
min_extrema = [e for e in extrema if e["type"] == "min"]
if min_extrema:
primary = min(min_extrema, key=lambda e: e["price"])
extreme_time = times[primary["idx"]] if primary["idx"] < len(times) else None
elif pattern == DAY_PATTERN_DOUBLE_PEAK and extrema:
elif pattern == DAY_PATTERN_DUCK_CURVE and extrema:
max_extrema = [e for e in extrema if e["type"] == "max"]
if max_extrema:
primary = max(max_extrema, key=lambda e: e["price"])
extreme_time = times[primary["idx"]] if primary["idx"] < len(times) else None
# The valley between the two peaks is the cheap zone for best-price periods.
# Compute knee points around the deepest minimum so that compute_geometric_flex_bonus
# can apply extra flex to intervals in this zone (same mechanism as VALLEY).
min_extrema_dp = [e for e in extrema if e["type"] == "min"]
if min_extrema_dp:
valley_extreme = min(min_extrema_dp, key=lambda e: e["price"])
lk, rk = _find_knee_points(smoothed, valley_extreme["idx"])
valley_start = times[lk] if lk is not None and lk < len(times) else None
valley_end = times[rk] if rk is not None and rk < len(times) else None
# ── intra-day segments ──────────────────────────────────────────────────────
segments = _detect_segments(extrema, prices_raw, times)
@ -278,24 +293,41 @@ def _find_significant_extrema(
return []
# ── raw local extrema (strict local min/max) ────────────────────────────────
# NOTE: We intentionally do NOT require the extremum to be below/above the
# day's start and end prices. That check was too restrictive for solar-
# influenced days (spring/summer) where overnight prices are as cheap as the
# midday valley, causing the midday dip to go undetected. The amplitude/
# prominence filter below is sufficient to suppress noise.
candidates: list[dict[str, Any]] = []
for i in range(1, n - 1):
prev_p = smoothed[i - 1]
cur_p = smoothed[i]
next_p = smoothed[i + 1]
if cur_p <= prev_p and cur_p <= next_p and cur_p < smoothed[0] and cur_p < smoothed[-1]:
if cur_p <= prev_p and cur_p <= next_p:
candidates.append({"idx": i, "type": "min", "price": cur_p})
elif cur_p >= prev_p and cur_p >= next_p and cur_p > smoothed[0] and cur_p > smoothed[-1]:
elif cur_p >= prev_p and cur_p >= next_p:
candidates.append({"idx": i, "type": "max", "price": cur_p})
if not candidates:
return []
# ── amplitude filter ────────────────────────────────────────────────────────
# For each candidate, compute prominence = distance to the nearest extremum
# of opposite type (or the global opposite extreme if none exist).
# We use a simpler heuristic: compare against the mean of its two flanking
# values in the smoothed series (one window radius on each side).
# For each candidate, measure prominence against the most representative
# reference price available.
#
# Problem with pure local-neighbourhood mean: a broad, flat-bottomed valley
# (e.g. a 5-hour cheap midday zone) pulls the neighbourhood mean down toward
# the valley price itself, making the prominence appear near-zero even though
# the valley is clearly significant on the full day.
#
# Solution: use max(local_mean, day_mean) for minima and min(local_mean,
# day_mean) for maxima. This picks the reference that gives the LARGEST
# separation for genuine extrema:
# - Deep/broad valley: local_mean ≈ valley price → day_mean wins (higher).
# - Overnight plateau max: local_mean ≈ plateau price → day_mean wins (lower).
# - Sharp isolated spike: local_mean already high → day_mean may be lower,
# but the spike still has large prominence either way.
day_mean = sum(smoothed) / len(smoothed)
significant: list[dict[str, Any]] = []
for cand in candidates:
idx = cand["idx"]
@ -303,11 +335,12 @@ def _find_significant_extrema(
lo = max(0, idx - hw)
hi = min(n, idx + hw + 1)
neighbourhood = smoothed[lo:hi]
local_mean = sum(neighbourhood) / len(neighbourhood)
if cand["type"] == "min":
reference = sum(neighbourhood) / len(neighbourhood)
reference = max(local_mean, day_mean) # broad valley: day_mean dominates
prominence = reference - cand["price"]
else:
reference = sum(neighbourhood) / len(neighbourhood)
reference = min(local_mean, day_mean) # plateau max: day_mean dominates
prominence = cand["price"] - reference
if prominence >= min_amplitude * 0.8: # slight tolerance on the threshold
significant.append(cand)
@ -348,14 +381,18 @@ def _classify_pattern(
extrema: list[dict[str, Any]],
cv_pct: float,
times: list[datetime],
start_price: float = 0.0,
end_price: float = 0.0,
) -> tuple[str, float]:
"""
Classify the day into a pattern string and confidence score (0-1).
Args:
extrema: List of significant extrema (already deduplicated).
cv_pct: Coefficient of variation for the day (%).
times: Timestamps of all intervals (for position calculations).
extrema: List of significant extrema (already deduplicated).
cv_pct: Coefficient of variation for the day (%).
times: Timestamps of all intervals (for position calculations).
start_price: Smoothed price of the first interval (day start).
end_price: Smoothed price of the last interval (day end).
Returns:
(pattern_string, confidence_float)
@ -369,11 +406,20 @@ def _classify_pattern(
confidence = max(0.5, 1.0 - cv_pct / FLAT_CV_THRESHOLD)
return DAY_PATTERN_FLAT, confidence
# ── no significant extrema → monotone (rising or falling) ──────────────────
# ── no significant extrema → check for monotone trend ──────────────────────
if not extrema:
# Cannot determine direction without access to underlying prices from here.
# The caller (_detect_single_day_pattern) handles the RISING/FALLING case
# before calling _classify_pattern when there are no extrema but prices exist.
# Without extrema, check if prices have a clear directional trend using
# the day's start/end price difference relative to span.
if start_price > 0 and end_price > 0 and n_times >= MIN_DAY_INTERVALS:
price_change = end_price - start_price
# Require at least 5% absolute change relative to the mean price to
# distinguish a genuine trend from flat-ish noise above FLAT_CV_THRESHOLD.
mean_price = (start_price + end_price) / 2
relative_change = abs(price_change) / mean_price if mean_price > 0 else 0
if relative_change > 0.05:
if price_change > 0:
return DAY_PATTERN_RISING, min(0.65, 0.4 + relative_change)
return DAY_PATTERN_FALLING, min(0.65, 0.4 + relative_change)
return DAY_PATTERN_MIXED, 0.4
n_extrema = len(extrema)
@ -401,22 +447,32 @@ def _classify_pattern(
# ── two extrema ─────────────────────────────────────────────────────────────
if n_extrema == 2:
if types == ["max", "min"]:
# Check if max is above both endpoints → genuine interior peak
max_price = extrema[0]["price"]
if start_price > 0 and end_price > 0 and max_price > start_price and max_price > end_price:
return DAY_PATTERN_PEAK, 0.65
return DAY_PATTERN_FALLING, 0.7
if types == ["min", "max"]:
# Check if min is below both endpoints → genuine interior valley
# (avoids misclassifying as RISING a day that starts/ends expensive
# but has a cheap midday zone, e.g. spring solar duck-curve).
min_price = extrema[0]["price"]
if start_price > 0 and end_price > 0 and min_price < start_price and min_price < end_price:
return DAY_PATTERN_VALLEY, 0.65
return DAY_PATTERN_RISING, 0.7
if types == ["min", "min"]:
return DAY_PATTERN_DOUBLE_VALLEY, 0.65
return DAY_PATTERN_DOUBLE_DIP, 0.65
if types == ["max", "max"]:
return DAY_PATTERN_DOUBLE_PEAK, 0.65
return DAY_PATTERN_DUCK_CURVE, 0.65
# ── three extrema ────────────────────────────────────────────────────────────
if n_extrema == 3:
# min-max-min → W-shape
if types == ["min", "max", "min"]:
return DAY_PATTERN_DOUBLE_VALLEY, 0.75
# max-min-max → M-shape
return DAY_PATTERN_DOUBLE_DIP, 0.75
# max-min-max → duck curve (solar midday valley between morning/evening peaks)
if types == ["max", "min", "max"]:
return DAY_PATTERN_DOUBLE_PEAK, 0.75
return DAY_PATTERN_DUCK_CURVE, 0.75
# min-max or max-min with trailing → RISING/FALLING with extra bump
if types[0] == "min" and types[-1] == "max":
return DAY_PATTERN_RISING, 0.55

View file

@ -282,8 +282,10 @@ def compute_geometric_flex_bonus(
zone_start = day_pattern.get("peak_start")
zone_end = day_pattern.get("peak_end")
else:
# Best price: expand inside VALLEY (V/U-shape) zone
if pattern != "valley":
# Best price: expand inside VALLEY zone.
# Also handles DUCK_CURVE (solar duck-curve: expensive morning/evening, cheap midday)
# where valley_start/valley_end mark the knee points around the midday minimum.
if pattern not in ("valley", "duck_curve"):
return 0.0
zone_start = day_pattern.get("valley_start")
zone_end = day_pattern.get("valley_end")

View file

@ -508,6 +508,11 @@ def _find_extension_intervals(
criteria: Any,
max_extension_time: datetime,
interval_duration: timedelta,
*,
max_intervals: int = 0,
period_mean_price: float = 0.0,
max_price_deviation: float = 0.0,
reverse_sort: bool = False,
) -> list[dict]:
"""
Find consecutive intervals after period_end that meet criteria.
@ -516,6 +521,14 @@ def _find_extension_intervals(
meet the flex and min_distance criteria. Stops at first failure
or when reaching max_extension_time.
Additional guards:
- max_intervals: Hard cap on number of extension intervals (0 = unlimited)
- period_mean_price + max_price_deviation: Stop extending when the candidate
interval's price deviates too far from the original period's mean price.
For peak periods (reverse_sort=True): stops when price drops below
mean × (1 - deviation). For best periods: stops when price rises above
mean × (1 + deviation).
"""
from .level_filtering import check_interval_criteria # noqa: PLC0415
@ -523,11 +536,26 @@ def _find_extension_intervals(
check_time = period_end
while check_time < max_extension_time:
# Hard cap on extension length
if max_intervals > 0 and len(extension_intervals) >= max_intervals:
break
price_data = price_lookup.get(check_time.isoformat())
if not price_data:
break # No more data
price = float(price_data["total"])
# Price deviation gate: stop if price drifts too far from original period mean
if period_mean_price > 0 and max_price_deviation > 0:
if reverse_sort:
# Peak: stop if price drops below mean × (1 - deviation)
if price < period_mean_price * (1 - max_price_deviation):
break
elif price > period_mean_price * (1 + max_price_deviation):
# Best: stop if price rises above mean × (1 + deviation)
break
in_flex, meets_min_distance = check_interval_criteria(price, criteria)
if not (in_flex and meets_min_distance):
@ -625,6 +653,9 @@ def extend_periods_across_midnight(
from .types import ( # noqa: PLC0415
CROSS_DAY_LATE_PERIOD_START_HOUR,
CROSS_DAY_MAX_EXTENSION_HOUR,
CROSS_DAY_MAX_EXTENSION_INTERVALS,
CROSS_DAY_MAX_PRICE_DEVIATION,
CROSS_DAY_PROPORTIONAL_EXTENSION_FACTOR,
PERIOD_MAX_CV,
TibberPricesIntervalCriteria,
)
@ -677,26 +708,40 @@ def extend_periods_across_midnight(
reverse_sort=reverse_sort,
)
# Find extension intervals
extension_intervals = _find_extension_intervals(
period["end"],
price_lookup,
criteria,
max_extension_time,
interval_duration,
)
if not extension_intervals:
extended_summaries.append(period)
continue
# Collect all prices for CV check
# Collect original prices once (reused for cap calculation, deviation gate, and CV check)
original_prices = _collect_original_period_prices(
period["start"],
period["end"],
price_lookup,
interval_duration,
)
# Calculate max extension intervals: min(hard cap, proportional cap)
original_interval_count = max(1, len(original_prices))
proportional_cap = int(original_interval_count * CROSS_DAY_PROPORTIONAL_EXTENSION_FACTOR)
max_intervals = min(CROSS_DAY_MAX_EXTENSION_INTERVALS, proportional_cap)
# Original period mean price for deviation gate
period_mean_price = sum(original_prices) / len(original_prices) if original_prices else 0.0
# Find extension intervals (with cap + price deviation gate)
extension_intervals = _find_extension_intervals(
period["end"],
price_lookup,
criteria,
max_extension_time,
interval_duration,
max_intervals=max_intervals,
period_mean_price=period_mean_price,
max_price_deviation=CROSS_DAY_MAX_PRICE_DEVIATION,
reverse_sort=reverse_sort,
)
if not extension_intervals:
extended_summaries.append(period)
continue
# CV check using already-collected original prices
extension_prices = [float(p["total"]) for p in extension_intervals]
combined_prices = original_prices + extension_prices
@ -714,11 +759,12 @@ def extend_periods_across_midnight(
)
_LOGGER.info(
"Cross-day extension: Period %s-%s extended to %s (+%d intervals, CV=%.1f%%)",
"Cross-day extension: Period %s-%s extended to %s (+%d intervals, max=%d, CV=%.1f%%)",
period["start"].strftime("%H:%M"),
period["end"].strftime("%H:%M"),
extended_period["end"].strftime("%H:%M"),
len(extension_intervals),
max_intervals,
combined_cv,
)
extended_summaries.append(extended_period)

View file

@ -284,6 +284,7 @@ def _try_min_duration_fallback(
existing_periods: list[dict],
prices_by_day: dict[date, list[dict]],
time: TibberPricesTimeService,
day_patterns_by_date: dict | None = None,
) -> tuple[dict[str, Any] | None, dict[str, Any]]:
"""
Try reducing min_period_length to find periods when relaxation is exhausted.
@ -303,6 +304,8 @@ def _try_min_duration_fallback(
existing_periods: Periods found so far (from relaxation)
prices_by_day: Price intervals grouped by day
time: Time service instance
day_patterns_by_date: Optional dict mapping date day pattern dict. Used for
geometric flex bonus in period detection.
Returns:
Tuple of (result dict with periods, metadata dict) or (None, empty metadata)
@ -362,6 +365,8 @@ def _try_min_duration_fallback(
threshold_volatility_very_high=config.threshold_volatility_very_high,
level_filter=None, # Disable level filter
gap_count=config.gap_count,
extend_to_extreme=config.extend_to_extreme,
max_extension_intervals=config.max_extension_intervals,
)
# Try to find periods for days with zero periods
@ -375,6 +380,7 @@ def _try_min_duration_fallback(
day_prices,
config=fallback_config,
time=time,
day_patterns_by_date=day_patterns_by_date,
)
day_periods = day_result.get("periods", [])
@ -813,6 +819,7 @@ def calculate_periods_with_relaxation(
existing_periods=all_periods,
prices_by_day=prices_by_day,
time=time,
day_patterns_by_date=day_patterns_by_date,
)
if fallback_result:

View file

@ -27,6 +27,7 @@ from typing import TYPE_CHECKING, Any
from custom_components.tibber_prices.const import (
PRICE_LEVEL_CHEAP,
PRICE_LEVEL_EXPENSIVE,
PRICE_LEVEL_MAPPING,
PRICE_LEVEL_VERY_CHEAP,
PRICE_LEVEL_VERY_EXPENSIVE,
)
@ -161,6 +162,67 @@ def _walk_contiguous(
return additions
def _fallback_blocked_by_majority(
intervals: list[dict[str, Any]],
primary_level: str,
fallback_level: str,
) -> bool:
"""Return ``True`` when fallback extension should be suppressed.
If *primary_level* intervals strictly outnumber *fallback_level* intervals
in the existing period, the period's character is predominantly primary.
Extending with *fallback_level* would dilute that character; the geometric
flex bonus of the core algorithm provides a better boundary in that case.
Args:
intervals: Existing period interval list.
primary_level: Preferred level (``VERY_CHEAP`` / ``VERY_EXPENSIVE``).
fallback_level: Extension candidate level (``CHEAP`` / ``EXPENSIVE``).
Returns:
``True`` if fallback extension should be blocked.
"""
primary_count = sum(1 for iv in intervals if iv.get("level") == primary_level)
fallback_count = sum(1 for iv in intervals if iv.get("level") == fallback_level)
return primary_count > fallback_count
def _is_spike_adjacent(
beyond_iv: dict[str, Any] | None,
fallback_level: str,
reverse_sort: bool,
) -> bool:
"""Return ``True`` when the interval just outside the extension is a spike.
If the interval immediately beyond the last collected fallback extension is
"worse" than *fallback_level* (more expensive for best-price, cheaper for
peak-price), the extension intervals form a ramp leading into a spike and
should be discarded.
Args:
beyond_iv: Interval dict just outside the collected extension, or ``None``.
fallback_level: The level used for the fallback extension.
reverse_sort: ``True`` for peak-price, ``False`` for best-price.
Returns:
``True`` if the extension should be dropped.
"""
if beyond_iv is None:
return False
beyond_level = beyond_iv.get("level")
if beyond_level is None:
return False
fallback_value = PRICE_LEVEL_MAPPING.get(fallback_level, 0)
beyond_value = PRICE_LEVEL_MAPPING.get(beyond_level, 0)
if reverse_sort:
# Peak: "worse" means cheaper than the extension level
return beyond_value < fallback_value
# Best: "worse" means more expensive than the extension level
return beyond_value > fallback_value
def _extend_period_edges(
period: dict[str, Any],
interval_index: dict[datetime, dict[str, Any]],
@ -200,28 +262,55 @@ def _extend_period_edges(
# ``end`` is the exclusive boundary: the last included interval starts at
# ``end - _INTERVAL_DURATION``.
reverse_sort = primary_level == PRICE_LEVEL_VERY_EXPENSIVE
backward_step = -_INTERVAL_DURATION
forward_step = _INTERVAL_DURATION
# Collect original intervals early needed for the majority gate below.
original_intervals = _collect_original_intervals(start, end, interval_index)
# ── walk LEFT (earlier than period start) ─────────────────────────────────
left_cursor = start - _INTERVAL_DURATION
left_additions = _walk_contiguous(interval_index, left_cursor, backward_step, primary_level, max_intervals)
left_used_fallback = False
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)
# Fallback: only if the period interior is not predominantly primary_level.
# When primary_level (e.g. VERY_CHEAP) strictly outnumbers fallback_level
# (e.g. CHEAP) inside the period, adding fallback edges dilutes the
# period's character. Rely on the geometric flex bonus instead.
if not _fallback_blocked_by_majority(original_intervals, primary_level, fallback_level):
left_additions = _walk_contiguous(interval_index, left_cursor, backward_step, fallback_level, max_intervals)
left_used_fallback = bool(left_additions)
# Look-beyond guard (fallback only): if the interval immediately outside the
# collected extensions is worse than fallback_level (e.g. a price spike just
# before a run of CHEAP intervals), those intervals form a ramp into the spike
# and should not be included.
if left_used_fallback:
one_beyond_left = start - _INTERVAL_DURATION * (len(left_additions) + 1)
if _is_spike_adjacent(interval_index.get(one_beyond_left), fallback_level, reverse_sort):
left_additions = []
# ── walk RIGHT (later than period end) ────────────────────────────────────
right_additions = _walk_contiguous(interval_index, end, forward_step, primary_level, max_intervals)
right_used_fallback = False
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)
# Fallback: same majority gate as left side.
if not _fallback_blocked_by_majority(original_intervals, primary_level, fallback_level):
right_additions = _walk_contiguous(interval_index, end, forward_step, fallback_level, max_intervals)
right_used_fallback = bool(right_additions)
# Look-beyond guard (fallback only).
if right_used_fallback:
one_beyond_right = end + _INTERVAL_DURATION * len(right_additions)
if _is_spike_adjacent(interval_index.get(one_beyond_right), fallback_level, reverse_sort):
right_additions = []
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 ────────────────────────────────────────────────
@ -256,7 +345,6 @@ def _extend_period_edges(
cv_pct = round(statistics.stdev(prices_for_vol) / mean_p * 100, 1)
# ── assemble updated period dict (keep structural fields, update statistics) ─
reverse_sort = primary_level == PRICE_LEVEL_VERY_EXPENSIVE
updated: dict[str, Any] = {
**period,
# Time fields

View file

@ -34,6 +34,9 @@ LOW_PRICE_QUALITY_BYPASS_THRESHOLD = 0.10 # EUR/NOK major unit (= 10 ct/øre)
# we can extend it past midnight if prices remain favorable
CROSS_DAY_LATE_PERIOD_START_HOUR = 20 # Consider periods starting at 20:00 or later for extension
CROSS_DAY_MAX_EXTENSION_HOUR = 8 # Don't extend beyond 08:00 next day (covers typical night low)
CROSS_DAY_MAX_EXTENSION_INTERVALS = 16 # Hard cap: max 4 hours of extension (16 × 15-minute intervals)
CROSS_DAY_PROPORTIONAL_EXTENSION_FACTOR = 2.0 # Extension ≤ 2× original period length
CROSS_DAY_MAX_PRICE_DEVIATION = 0.15 # Stop if price deviates >15% from original period mean
# Cross-Day Supersession: When tomorrow data arrives, late-night periods that are
# worse than early-morning tomorrow periods become obsolete
@ -122,8 +125,8 @@ class TibberPricesIntervalCriteria(NamedTuple):
DAY_PATTERN_VALLEY = "valley" # Single price minimum (U/V-shape)
DAY_PATTERN_PEAK = "peak" # Single price maximum (Λ-shape)
DAY_PATTERN_DOUBLE_VALLEY = "double_valley" # Two minima, W-shape
DAY_PATTERN_DOUBLE_PEAK = "double_peak" # Two peaks, M-shape
DAY_PATTERN_DOUBLE_DIP = "double_dip" # Two minima, W-shape
DAY_PATTERN_DUCK_CURVE = "duck_curve" # Two peaks with midday valley (solar duck curve)
DAY_PATTERN_FLAT = "flat" # No significant variation
DAY_PATTERN_RISING = "rising" # Persistently rising throughout the day
DAY_PATTERN_FALLING = "falling" # Persistently falling throughout the day
@ -133,8 +136,8 @@ DAY_PATTERN_MIXED = "mixed" # Multiple extrema with no clear pattern
ALL_DAY_PATTERNS: list[str] = [
DAY_PATTERN_VALLEY,
DAY_PATTERN_PEAK,
DAY_PATTERN_DOUBLE_VALLEY,
DAY_PATTERN_DOUBLE_PEAK,
DAY_PATTERN_DOUBLE_DIP,
DAY_PATTERN_DUCK_CURVE,
DAY_PATTERN_FLAT,
DAY_PATTERN_RISING,
DAY_PATTERN_FALLING,

View file

@ -1,11 +1,15 @@
{
"domain": "tibber_prices",
"name": "Tibber Price Information & Ratings",
"codeowners": ["@jpawlowski"],
"codeowners": [
"@jpawlowski"
],
"config_flow": true,
"documentation": "https://github.com/jpawlowski/hass.tibber_prices",
"iot_class": "cloud_polling",
"issue_tracker": "https://github.com/jpawlowski/hass.tibber_prices/issues",
"requirements": ["aiofiles>=23.2.1"],
"version": "0.31.0b2"
"requirements": [
"aiofiles>=23.2.1"
],
"version": "0.31.0b3"
}

View file

@ -999,8 +999,8 @@ DAY_PATTERN_SENSORS = (
options=[
"valley",
"peak",
"double_valley",
"double_peak",
"double_dip",
"duck_curve",
"flat",
"rising",
"falling",
@ -1017,8 +1017,8 @@ DAY_PATTERN_SENSORS = (
options=[
"valley",
"peak",
"double_valley",
"double_peak",
"double_dip",
"duck_curve",
"flat",
"rising",
"falling",
@ -1035,8 +1035,8 @@ DAY_PATTERN_SENSORS = (
options=[
"valley",
"peak",
"double_valley",
"double_peak",
"double_dip",
"duck_curve",
"flat",
"rising",
"falling",

View file

@ -978,8 +978,8 @@
"state": {
"valley": "Tal",
"peak": "Gipfel",
"double_valley": "Doppeltal",
"double_peak": "Doppelgipfel",
"double_dip": "Doppelmulde",
"duck_curve": "Entenkurve",
"flat": "Flach",
"rising": "Steigend",
"falling": "Fallend",
@ -991,8 +991,8 @@
"state": {
"valley": "Tal",
"peak": "Gipfel",
"double_valley": "Doppeltal",
"double_peak": "Doppelgipfel",
"double_dip": "Doppelmulde",
"duck_curve": "Entenkurve",
"flat": "Flach",
"rising": "Steigend",
"falling": "Fallend",
@ -1004,8 +1004,8 @@
"state": {
"valley": "Tal",
"peak": "Gipfel",
"double_valley": "Doppeltal",
"double_peak": "Doppelgipfel",
"double_dip": "Doppelmulde",
"duck_curve": "Entenkurve",
"flat": "Flach",
"rising": "Steigend",
"falling": "Fallend",

View file

@ -264,7 +264,7 @@
"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_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_dip' 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"
}
}
@ -329,7 +329,7 @@
"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_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 'duck_curve' 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"
}
}
@ -978,8 +978,8 @@
"state": {
"valley": "Valley",
"peak": "Peak",
"double_valley": "Double Valley",
"double_peak": "Double Peak",
"double_dip": "Double Dip",
"duck_curve": "Duck Curve",
"flat": "Flat",
"rising": "Rising",
"falling": "Falling",
@ -991,8 +991,8 @@
"state": {
"valley": "Valley",
"peak": "Peak",
"double_valley": "Double Valley",
"double_peak": "Double Peak",
"double_dip": "Double Dip",
"duck_curve": "Duck Curve",
"flat": "Flat",
"rising": "Rising",
"falling": "Falling",
@ -1004,8 +1004,8 @@
"state": {
"valley": "Valley",
"peak": "Peak",
"double_valley": "Double Valley",
"double_peak": "Double Peak",
"double_dip": "Double Dip",
"duck_curve": "Duck Curve",
"flat": "Flat",
"rising": "Rising",
"falling": "Falling",

View file

@ -978,8 +978,8 @@
"state": {
"valley": "Dal",
"peak": "Topp",
"double_valley": "Dobbel dal",
"double_peak": "Dobbel topp",
"double_dip": "Dobbel bunn",
"duck_curve": "Andekurve",
"flat": "Flat",
"rising": "Stigende",
"falling": "Fallende",
@ -991,8 +991,8 @@
"state": {
"valley": "Dal",
"peak": "Topp",
"double_valley": "Dobbel dal",
"double_peak": "Dobbel topp",
"double_dip": "Dobbel bunn",
"duck_curve": "Andekurve",
"flat": "Flat",
"rising": "Stigende",
"falling": "Fallende",
@ -1004,8 +1004,8 @@
"state": {
"valley": "Dal",
"peak": "Topp",
"double_valley": "Dobbel dal",
"double_peak": "Dobbel topp",
"double_dip": "Dobbel bunn",
"duck_curve": "Andekurve",
"flat": "Flat",
"rising": "Stigende",
"falling": "Fallende",

View file

@ -978,8 +978,8 @@
"state": {
"valley": "Dal",
"peak": "Piek",
"double_valley": "Dubbel Dal",
"double_peak": "Dubbele Piek",
"double_dip": "Dubbele Dip",
"duck_curve": "Eendcurve",
"flat": "Vlak",
"rising": "Stijgend",
"falling": "Dalend",
@ -991,8 +991,8 @@
"state": {
"valley": "Dal",
"peak": "Piek",
"double_valley": "Dubbel Dal",
"double_peak": "Dubbele Piek",
"double_dip": "Dubbele Dip",
"duck_curve": "Eendcurve",
"flat": "Vlak",
"rising": "Stijgend",
"falling": "Dalend",
@ -1004,8 +1004,8 @@
"state": {
"valley": "Dal",
"peak": "Piek",
"double_valley": "Dubbel Dal",
"double_peak": "Dubbele Piek",
"double_dip": "Dubbele Dip",
"duck_curve": "Eendcurve",
"flat": "Vlak",
"rising": "Stijgend",
"falling": "Dalend",

View file

@ -978,8 +978,8 @@
"state": {
"valley": "Dal",
"peak": "Topp",
"double_valley": "Dubbeldal",
"double_peak": "Dubbeltopp",
"double_dip": "Dubbel dipp",
"duck_curve": "Ankkurva",
"flat": "Flat",
"rising": "Stigande",
"falling": "Fallande",
@ -991,8 +991,8 @@
"state": {
"valley": "Dal",
"peak": "Topp",
"double_valley": "Dubbeldal",
"double_peak": "Dubbeltopp",
"double_dip": "Dubbel dipp",
"duck_curve": "Ankkurva",
"flat": "Flat",
"rising": "Stigande",
"falling": "Fallande",
@ -1004,8 +1004,8 @@
"state": {
"valley": "Dal",
"peak": "Topp",
"double_valley": "Dubbeldal",
"double_peak": "Dubbeltopp",
"double_dip": "Dubbel dipp",
"duck_curve": "Ankkurva",
"flat": "Flat",
"rising": "Stigande",
"falling": "Fallande",

View file

@ -375,13 +375,8 @@ Then sections using ### (H3 headings):
Skip any section that has no content.
After the last section, ALWAYS end with this exact footer, no modifications:
---
If this release saved you some money on your electricity bill, a coffee would be much appreciated! ☕
[![Buy Me A Coffee](https://img.buymeacoffee.com/button-api/?text=Buy%20me%20a%20coffee&emoji=☕&slug=jpawlowski&button_colour=FFDD00&font_colour=000000&font_family=Cookie&outline_colour=000000&coffee_colour=ffffff)](https://www.buymeacoffee.com/jpawlowski)
Do NOT add any footer or sign-off. End your response after the last content section.
A footer is appended automatically after your response.
## TITLE SELECTION
- Find the most user-impactful change: new sensors > bug fixes affecting data quality > reliability improvements > UI improvements > translations
@ -466,6 +461,60 @@ End after the Buy Me A Coffee button. No meta-commentary, no explanations."
rm -f "$TEMP_PROMPT"
}
# ============================================================================
# Canonical footer (appended by ensure_canonical_footer, backend-independent)
# ============================================================================
# Defined here so it is the single source of truth for all backends.
# cliff.toml also contains this footer for direct git-cliff usage;
# ensure_canonical_footer strips any existing variant before re-appending.
CANONICAL_FOOTER='---
If this release saved you some money on your electricity bill, a coffee would be much appreciated! ☕
[![Buy Me A Coffee](https://img.buymeacoffee.com/button-api/?text=Buy%20me%20a%20coffee&emoji=☕&slug=jpawlowski&button_colour=FFDD00&font_colour=000000&font_family=Cookie&outline_colour=000000&coffee_colour=ffffff)](https://www.buymeacoffee.com/jpawlowski)'
# Strip any existing BMAC footer block (AI/template variant) then append the
# canonical footer defined above. Reads from stdin, writes to stdout.
# This guarantees all backends produce an identical footer.
ensure_canonical_footer() {
awk '
{
lines[NR] = $0
}
END {
# Find the last buymeacoffee occurrence
bmac_line = 0
for (i = NR; i >= 1; i--) {
if (lines[i] ~ /buymeacoffee\.com\/jpawlowski/) {
bmac_line = i
break
}
}
cut_at = NR
if (bmac_line > 0) {
# Walk back to find the nearest --- separator before the link
for (i = bmac_line - 1; i >= 1; i--) {
if (lines[i] ~ /^---[[:space:]]*$/) {
cut_at = i - 1 # Exclude the separator itself (footer provides its own)
break
}
}
# No --- found: cut at the buymeacoffee line itself
if (cut_at == NR) cut_at = bmac_line
}
# Trim trailing blank lines before the cut point
while (cut_at > 0 && lines[cut_at] ~ /^[[:space:]]*$/) cut_at--
for (i = 1; i <= cut_at; i++) print lines[i]
}
'
printf "\n%s\n" "$CANONICAL_FOOTER"
}
# Backend: git-cliff (template-based)
generate_with_gitcliff() {
log_info "${BLUE}==> Generating with git-cliff${NC}"
@ -829,26 +878,26 @@ if [ "$AUTO_UPDATE_AVAILABLE" = "true" ]; then
TEMP_NOTES=$(mktemp)
case "$BACKEND" in
copilot)
generate_with_copilot >"$TEMP_NOTES"
generate_with_copilot | ensure_canonical_footer >"$TEMP_NOTES"
;;
git-cliff)
generate_with_gitcliff >"$TEMP_NOTES"
generate_with_gitcliff | ensure_canonical_footer >"$TEMP_NOTES"
;;
manual)
generate_with_manual >"$TEMP_NOTES"
generate_with_manual | ensure_canonical_footer >"$TEMP_NOTES"
;;
esac
else
# No auto-update, just output to stdout
case "$BACKEND" in
copilot)
generate_with_copilot
generate_with_copilot | ensure_canonical_footer
;;
git-cliff)
generate_with_gitcliff
generate_with_gitcliff | ensure_canonical_footer
;;
manual)
generate_with_manual
generate_with_manual | ensure_canonical_footer
;;
esac