mirror of
https://github.com/jpawlowski/hass.tibber_prices.git
synced 2026-05-28 18:43:40 +00:00
Compare commits
4 commits
db02f262b6
...
75da094c81
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
75da094c81 | ||
|
|
ba3e127ac7 | ||
|
|
2092d28ece | ||
|
|
432eb6502c |
16 changed files with 374 additions and 119 deletions
|
|
@ -34,8 +34,8 @@ 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,
|
||||||
DAY_PATTERN_DOUBLE_PEAK,
|
DAY_PATTERN_DOUBLE_DIP,
|
||||||
DAY_PATTERN_DOUBLE_VALLEY,
|
DAY_PATTERN_DUCK_CURVE,
|
||||||
DAY_PATTERN_FALLING,
|
DAY_PATTERN_FALLING,
|
||||||
DAY_PATTERN_FLAT,
|
DAY_PATTERN_FLAT,
|
||||||
DAY_PATTERN_MIXED,
|
DAY_PATTERN_MIXED,
|
||||||
|
|
@ -59,8 +59,8 @@ from .types import (
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"ALL_DAY_PATTERNS",
|
"ALL_DAY_PATTERNS",
|
||||||
"DAY_PATTERN_DOUBLE_PEAK",
|
"DAY_PATTERN_DOUBLE_DIP",
|
||||||
"DAY_PATTERN_DOUBLE_VALLEY",
|
"DAY_PATTERN_DUCK_CURVE",
|
||||||
"DAY_PATTERN_FALLING",
|
"DAY_PATTERN_FALLING",
|
||||||
"DAY_PATTERN_FLAT",
|
"DAY_PATTERN_FLAT",
|
||||||
"DAY_PATTERN_MIXED",
|
"DAY_PATTERN_MIXED",
|
||||||
|
|
|
||||||
|
|
@ -183,7 +183,7 @@ def calculate_periods(
|
||||||
)
|
)
|
||||||
|
|
||||||
# Step 3.5: Segment forcing for W/M-shaped days (opt-in, default disabled)
|
# 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.
|
# ensures each price valley/peak segment has at least segment_min_periods periods.
|
||||||
if config.segment_forcing and day_patterns_by_date:
|
if config.segment_forcing and day_patterns_by_date:
|
||||||
raw_periods = _apply_segment_forcing(
|
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.
|
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.
|
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.
|
ensures each peak side has the required number of periods.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
|
|
@ -335,12 +335,12 @@ def _apply_segment_forcing(
|
||||||
import logging # noqa: PLC0415
|
import logging # noqa: PLC0415
|
||||||
|
|
||||||
from .period_building import build_periods # 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__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
reverse_sort = config.reverse_sort
|
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
|
segment_min_periods = config.segment_min_periods
|
||||||
|
|
||||||
merged_periods = list(periods)
|
merged_periods = list(periods)
|
||||||
|
|
@ -362,8 +362,8 @@ def _apply_segment_forcing(
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Find the central extremum in the middle 50% of the day
|
# Find the central extremum in the middle 50% of the day
|
||||||
# DOUBLE_VALLEY → central peak = highest price between the two valleys
|
# DOUBLE_DIP → central peak = highest price between the two valleys
|
||||||
# DOUBLE_PEAK → central valley = lowest price between the two peaks
|
# DUCK_CURVE → central valley = lowest price between the two peaks
|
||||||
n = len(day_intervals)
|
n = len(day_intervals)
|
||||||
middle = day_intervals[n // 4 : 3 * n // 4]
|
middle = day_intervals[n // 4 : 3 * n // 4]
|
||||||
if not middle:
|
if not middle:
|
||||||
|
|
|
||||||
|
|
@ -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)
|
VALLEY - Single price minimum (U/V-shape, cheap middle)
|
||||||
PEAK - Single price maximum (Lambda-shape, expensive middle)
|
PEAK - Single price maximum (Lambda-shape, expensive middle)
|
||||||
DOUBLE_VALLEY - Two minima separated by a peak (W-shape)
|
DOUBLE_DIP - Two minima separated by a peak (W-shape)
|
||||||
DOUBLE_PEAK - Two peaks separated by a valley (M-shape)
|
DUCK_CURVE - Two peaks with midday valley (M-shape, solar duck curve)
|
||||||
FLAT - No significant variation (CV <= 10 %)
|
FLAT - No significant variation (CV <= 10 %)
|
||||||
RISING - Monotonically / persistently rising
|
RISING - Monotonically / persistently rising
|
||||||
FALLING - Monotonically / persistently falling
|
FALLING - Monotonically / persistently falling
|
||||||
|
|
@ -63,8 +63,8 @@ _EDGE_ZONE = 0.25
|
||||||
# Pattern string constants
|
# Pattern string constants
|
||||||
DAY_PATTERN_VALLEY = "valley"
|
DAY_PATTERN_VALLEY = "valley"
|
||||||
DAY_PATTERN_PEAK = "peak"
|
DAY_PATTERN_PEAK = "peak"
|
||||||
DAY_PATTERN_DOUBLE_VALLEY = "double_valley"
|
DAY_PATTERN_DOUBLE_DIP = "double_dip"
|
||||||
DAY_PATTERN_DOUBLE_PEAK = "double_peak"
|
DAY_PATTERN_DUCK_CURVE = "duck_curve"
|
||||||
DAY_PATTERN_FLAT = "flat"
|
DAY_PATTERN_FLAT = "flat"
|
||||||
DAY_PATTERN_RISING = "rising"
|
DAY_PATTERN_RISING = "rising"
|
||||||
DAY_PATTERN_FALLING = "falling"
|
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)
|
extrema = _find_significant_extrema(smoothed, min_amplitude=price_span * MIN_EXTREMUM_AMPLITUDE_RATIO)
|
||||||
|
|
||||||
# ── classify pattern ────────────────────────────────────────────────────────
|
# ── 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 ─────────────────────────────────────
|
# ── knee points + primary extreme time ─────────────────────────────────────
|
||||||
extreme_time: datetime | None = None
|
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_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
|
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
|
# Primary extreme = deeper of the two minima
|
||||||
min_extrema = [e for e in extrema if e["type"] == "min"]
|
min_extrema = [e for e in extrema if e["type"] == "min"]
|
||||||
if min_extrema:
|
if min_extrema:
|
||||||
primary = min(min_extrema, key=lambda e: e["price"])
|
primary = min(min_extrema, key=lambda e: e["price"])
|
||||||
extreme_time = times[primary["idx"]] if primary["idx"] < len(times) else None
|
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"]
|
max_extrema = [e for e in extrema if e["type"] == "max"]
|
||||||
if max_extrema:
|
if max_extrema:
|
||||||
primary = max(max_extrema, key=lambda e: e["price"])
|
primary = max(max_extrema, key=lambda e: e["price"])
|
||||||
extreme_time = times[primary["idx"]] if primary["idx"] < len(times) else None
|
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 ──────────────────────────────────────────────────────
|
# ── intra-day segments ──────────────────────────────────────────────────────
|
||||||
segments = _detect_segments(extrema, prices_raw, times)
|
segments = _detect_segments(extrema, prices_raw, times)
|
||||||
|
|
@ -278,24 +293,41 @@ def _find_significant_extrema(
|
||||||
return []
|
return []
|
||||||
|
|
||||||
# ── raw local extrema (strict local min/max) ────────────────────────────────
|
# ── 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]] = []
|
candidates: list[dict[str, Any]] = []
|
||||||
for i in range(1, n - 1):
|
for i in range(1, n - 1):
|
||||||
prev_p = smoothed[i - 1]
|
prev_p = smoothed[i - 1]
|
||||||
cur_p = smoothed[i]
|
cur_p = smoothed[i]
|
||||||
next_p = smoothed[i + 1]
|
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})
|
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})
|
candidates.append({"idx": i, "type": "max", "price": cur_p})
|
||||||
|
|
||||||
if not candidates:
|
if not candidates:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
# ── amplitude filter ────────────────────────────────────────────────────────
|
# ── amplitude filter ────────────────────────────────────────────────────────
|
||||||
# For each candidate, compute prominence = distance to the nearest extremum
|
# For each candidate, measure prominence against the most representative
|
||||||
# of opposite type (or the global opposite extreme if none exist).
|
# reference price available.
|
||||||
# We use a simpler heuristic: compare against the mean of its two flanking
|
#
|
||||||
# values in the smoothed series (one window radius on each side).
|
# 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]] = []
|
significant: list[dict[str, Any]] = []
|
||||||
for cand in candidates:
|
for cand in candidates:
|
||||||
idx = cand["idx"]
|
idx = cand["idx"]
|
||||||
|
|
@ -303,11 +335,12 @@ def _find_significant_extrema(
|
||||||
lo = max(0, idx - hw)
|
lo = max(0, idx - hw)
|
||||||
hi = min(n, idx + hw + 1)
|
hi = min(n, idx + hw + 1)
|
||||||
neighbourhood = smoothed[lo:hi]
|
neighbourhood = smoothed[lo:hi]
|
||||||
|
local_mean = sum(neighbourhood) / len(neighbourhood)
|
||||||
if cand["type"] == "min":
|
if cand["type"] == "min":
|
||||||
reference = sum(neighbourhood) / len(neighbourhood)
|
reference = max(local_mean, day_mean) # broad valley: day_mean dominates
|
||||||
prominence = reference - cand["price"]
|
prominence = reference - cand["price"]
|
||||||
else:
|
else:
|
||||||
reference = sum(neighbourhood) / len(neighbourhood)
|
reference = min(local_mean, day_mean) # plateau max: day_mean dominates
|
||||||
prominence = cand["price"] - reference
|
prominence = cand["price"] - reference
|
||||||
if prominence >= min_amplitude * 0.8: # slight tolerance on the threshold
|
if prominence >= min_amplitude * 0.8: # slight tolerance on the threshold
|
||||||
significant.append(cand)
|
significant.append(cand)
|
||||||
|
|
@ -348,14 +381,18 @@ def _classify_pattern(
|
||||||
extrema: list[dict[str, Any]],
|
extrema: list[dict[str, Any]],
|
||||||
cv_pct: float,
|
cv_pct: float,
|
||||||
times: list[datetime],
|
times: list[datetime],
|
||||||
|
start_price: float = 0.0,
|
||||||
|
end_price: float = 0.0,
|
||||||
) -> tuple[str, float]:
|
) -> tuple[str, float]:
|
||||||
"""
|
"""
|
||||||
Classify the day into a pattern string and confidence score (0-1).
|
Classify the day into a pattern string and confidence score (0-1).
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
extrema: List of significant extrema (already deduplicated).
|
extrema: List of significant extrema (already deduplicated).
|
||||||
cv_pct: Coefficient of variation for the day (%).
|
cv_pct: Coefficient of variation for the day (%).
|
||||||
times: Timestamps of all intervals (for position calculations).
|
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:
|
Returns:
|
||||||
(pattern_string, confidence_float)
|
(pattern_string, confidence_float)
|
||||||
|
|
@ -369,11 +406,20 @@ def _classify_pattern(
|
||||||
confidence = max(0.5, 1.0 - cv_pct / FLAT_CV_THRESHOLD)
|
confidence = max(0.5, 1.0 - cv_pct / FLAT_CV_THRESHOLD)
|
||||||
return DAY_PATTERN_FLAT, confidence
|
return DAY_PATTERN_FLAT, confidence
|
||||||
|
|
||||||
# ── no significant extrema → monotone (rising or falling) ──────────────────
|
# ── no significant extrema → check for monotone trend ──────────────────────
|
||||||
if not extrema:
|
if not extrema:
|
||||||
# Cannot determine direction without access to underlying prices from here.
|
# Without extrema, check if prices have a clear directional trend using
|
||||||
# The caller (_detect_single_day_pattern) handles the RISING/FALLING case
|
# the day's start/end price difference relative to span.
|
||||||
# before calling _classify_pattern when there are no extrema but prices exist.
|
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
|
return DAY_PATTERN_MIXED, 0.4
|
||||||
|
|
||||||
n_extrema = len(extrema)
|
n_extrema = len(extrema)
|
||||||
|
|
@ -401,22 +447,32 @@ def _classify_pattern(
|
||||||
# ── two extrema ─────────────────────────────────────────────────────────────
|
# ── two extrema ─────────────────────────────────────────────────────────────
|
||||||
if n_extrema == 2:
|
if n_extrema == 2:
|
||||||
if types == ["max", "min"]:
|
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
|
return DAY_PATTERN_FALLING, 0.7
|
||||||
if types == ["min", "max"]:
|
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
|
return DAY_PATTERN_RISING, 0.7
|
||||||
if types == ["min", "min"]:
|
if types == ["min", "min"]:
|
||||||
return DAY_PATTERN_DOUBLE_VALLEY, 0.65
|
return DAY_PATTERN_DOUBLE_DIP, 0.65
|
||||||
if types == ["max", "max"]:
|
if types == ["max", "max"]:
|
||||||
return DAY_PATTERN_DOUBLE_PEAK, 0.65
|
return DAY_PATTERN_DUCK_CURVE, 0.65
|
||||||
|
|
||||||
# ── three extrema ────────────────────────────────────────────────────────────
|
# ── three extrema ────────────────────────────────────────────────────────────
|
||||||
if n_extrema == 3:
|
if n_extrema == 3:
|
||||||
# min-max-min → W-shape
|
# min-max-min → W-shape
|
||||||
if types == ["min", "max", "min"]:
|
if types == ["min", "max", "min"]:
|
||||||
return DAY_PATTERN_DOUBLE_VALLEY, 0.75
|
return DAY_PATTERN_DOUBLE_DIP, 0.75
|
||||||
# max-min-max → M-shape
|
# max-min-max → duck curve (solar midday valley between morning/evening peaks)
|
||||||
if types == ["max", "min", "max"]:
|
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
|
# min-max or max-min with trailing → RISING/FALLING with extra bump
|
||||||
if types[0] == "min" and types[-1] == "max":
|
if types[0] == "min" and types[-1] == "max":
|
||||||
return DAY_PATTERN_RISING, 0.55
|
return DAY_PATTERN_RISING, 0.55
|
||||||
|
|
|
||||||
|
|
@ -282,8 +282,10 @@ def compute_geometric_flex_bonus(
|
||||||
zone_start = day_pattern.get("peak_start")
|
zone_start = day_pattern.get("peak_start")
|
||||||
zone_end = day_pattern.get("peak_end")
|
zone_end = day_pattern.get("peak_end")
|
||||||
else:
|
else:
|
||||||
# Best price: expand inside VALLEY (V/U-shape) zone
|
# Best price: expand inside VALLEY zone.
|
||||||
if pattern != "valley":
|
# 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
|
return 0.0
|
||||||
zone_start = day_pattern.get("valley_start")
|
zone_start = day_pattern.get("valley_start")
|
||||||
zone_end = day_pattern.get("valley_end")
|
zone_end = day_pattern.get("valley_end")
|
||||||
|
|
|
||||||
|
|
@ -508,6 +508,11 @@ def _find_extension_intervals(
|
||||||
criteria: Any,
|
criteria: Any,
|
||||||
max_extension_time: datetime,
|
max_extension_time: datetime,
|
||||||
interval_duration: timedelta,
|
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]:
|
) -> list[dict]:
|
||||||
"""
|
"""
|
||||||
Find consecutive intervals after period_end that meet criteria.
|
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
|
meet the flex and min_distance criteria. Stops at first failure
|
||||||
or when reaching max_extension_time.
|
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
|
from .level_filtering import check_interval_criteria # noqa: PLC0415
|
||||||
|
|
||||||
|
|
@ -523,11 +536,26 @@ def _find_extension_intervals(
|
||||||
check_time = period_end
|
check_time = period_end
|
||||||
|
|
||||||
while check_time < max_extension_time:
|
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())
|
price_data = price_lookup.get(check_time.isoformat())
|
||||||
if not price_data:
|
if not price_data:
|
||||||
break # No more data
|
break # No more data
|
||||||
|
|
||||||
price = float(price_data["total"])
|
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)
|
in_flex, meets_min_distance = check_interval_criteria(price, criteria)
|
||||||
|
|
||||||
if not (in_flex and meets_min_distance):
|
if not (in_flex and meets_min_distance):
|
||||||
|
|
@ -625,6 +653,9 @@ def extend_periods_across_midnight(
|
||||||
from .types import ( # noqa: PLC0415
|
from .types import ( # noqa: PLC0415
|
||||||
CROSS_DAY_LATE_PERIOD_START_HOUR,
|
CROSS_DAY_LATE_PERIOD_START_HOUR,
|
||||||
CROSS_DAY_MAX_EXTENSION_HOUR,
|
CROSS_DAY_MAX_EXTENSION_HOUR,
|
||||||
|
CROSS_DAY_MAX_EXTENSION_INTERVALS,
|
||||||
|
CROSS_DAY_MAX_PRICE_DEVIATION,
|
||||||
|
CROSS_DAY_PROPORTIONAL_EXTENSION_FACTOR,
|
||||||
PERIOD_MAX_CV,
|
PERIOD_MAX_CV,
|
||||||
TibberPricesIntervalCriteria,
|
TibberPricesIntervalCriteria,
|
||||||
)
|
)
|
||||||
|
|
@ -677,26 +708,40 @@ def extend_periods_across_midnight(
|
||||||
reverse_sort=reverse_sort,
|
reverse_sort=reverse_sort,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Find extension intervals
|
# Collect original prices once (reused for cap calculation, deviation gate, and CV check)
|
||||||
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
|
|
||||||
original_prices = _collect_original_period_prices(
|
original_prices = _collect_original_period_prices(
|
||||||
period["start"],
|
period["start"],
|
||||||
period["end"],
|
period["end"],
|
||||||
price_lookup,
|
price_lookup,
|
||||||
interval_duration,
|
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]
|
extension_prices = [float(p["total"]) for p in extension_intervals]
|
||||||
combined_prices = original_prices + extension_prices
|
combined_prices = original_prices + extension_prices
|
||||||
|
|
||||||
|
|
@ -714,11 +759,12 @@ def extend_periods_across_midnight(
|
||||||
)
|
)
|
||||||
|
|
||||||
_LOGGER.info(
|
_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["start"].strftime("%H:%M"),
|
||||||
period["end"].strftime("%H:%M"),
|
period["end"].strftime("%H:%M"),
|
||||||
extended_period["end"].strftime("%H:%M"),
|
extended_period["end"].strftime("%H:%M"),
|
||||||
len(extension_intervals),
|
len(extension_intervals),
|
||||||
|
max_intervals,
|
||||||
combined_cv,
|
combined_cv,
|
||||||
)
|
)
|
||||||
extended_summaries.append(extended_period)
|
extended_summaries.append(extended_period)
|
||||||
|
|
|
||||||
|
|
@ -284,6 +284,7 @@ def _try_min_duration_fallback(
|
||||||
existing_periods: list[dict],
|
existing_periods: list[dict],
|
||||||
prices_by_day: dict[date, list[dict]],
|
prices_by_day: dict[date, list[dict]],
|
||||||
time: TibberPricesTimeService,
|
time: TibberPricesTimeService,
|
||||||
|
day_patterns_by_date: dict | None = None,
|
||||||
) -> tuple[dict[str, Any] | None, dict[str, Any]]:
|
) -> tuple[dict[str, Any] | None, dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
Try reducing min_period_length to find periods when relaxation is exhausted.
|
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)
|
existing_periods: Periods found so far (from relaxation)
|
||||||
prices_by_day: Price intervals grouped by day
|
prices_by_day: Price intervals grouped by day
|
||||||
time: Time service instance
|
time: Time service instance
|
||||||
|
day_patterns_by_date: Optional dict mapping date → day pattern dict. Used for
|
||||||
|
geometric flex bonus in period detection.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Tuple of (result dict with periods, metadata dict) or (None, empty metadata)
|
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,
|
threshold_volatility_very_high=config.threshold_volatility_very_high,
|
||||||
level_filter=None, # Disable level filter
|
level_filter=None, # Disable level filter
|
||||||
gap_count=config.gap_count,
|
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
|
# Try to find periods for days with zero periods
|
||||||
|
|
@ -375,6 +380,7 @@ def _try_min_duration_fallback(
|
||||||
day_prices,
|
day_prices,
|
||||||
config=fallback_config,
|
config=fallback_config,
|
||||||
time=time,
|
time=time,
|
||||||
|
day_patterns_by_date=day_patterns_by_date,
|
||||||
)
|
)
|
||||||
|
|
||||||
day_periods = day_result.get("periods", [])
|
day_periods = day_result.get("periods", [])
|
||||||
|
|
@ -813,6 +819,7 @@ def calculate_periods_with_relaxation(
|
||||||
existing_periods=all_periods,
|
existing_periods=all_periods,
|
||||||
prices_by_day=prices_by_day,
|
prices_by_day=prices_by_day,
|
||||||
time=time,
|
time=time,
|
||||||
|
day_patterns_by_date=day_patterns_by_date,
|
||||||
)
|
)
|
||||||
|
|
||||||
if fallback_result:
|
if fallback_result:
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ from typing import TYPE_CHECKING, Any
|
||||||
from custom_components.tibber_prices.const import (
|
from custom_components.tibber_prices.const import (
|
||||||
PRICE_LEVEL_CHEAP,
|
PRICE_LEVEL_CHEAP,
|
||||||
PRICE_LEVEL_EXPENSIVE,
|
PRICE_LEVEL_EXPENSIVE,
|
||||||
|
PRICE_LEVEL_MAPPING,
|
||||||
PRICE_LEVEL_VERY_CHEAP,
|
PRICE_LEVEL_VERY_CHEAP,
|
||||||
PRICE_LEVEL_VERY_EXPENSIVE,
|
PRICE_LEVEL_VERY_EXPENSIVE,
|
||||||
)
|
)
|
||||||
|
|
@ -161,6 +162,67 @@ def _walk_contiguous(
|
||||||
return additions
|
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(
|
def _extend_period_edges(
|
||||||
period: dict[str, Any],
|
period: dict[str, Any],
|
||||||
interval_index: dict[datetime, 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`` is the exclusive boundary: the last included interval starts at
|
||||||
# ``end - _INTERVAL_DURATION``.
|
# ``end - _INTERVAL_DURATION``.
|
||||||
|
|
||||||
|
reverse_sort = primary_level == PRICE_LEVEL_VERY_EXPENSIVE
|
||||||
backward_step = -_INTERVAL_DURATION
|
backward_step = -_INTERVAL_DURATION
|
||||||
forward_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) ─────────────────────────────────
|
# ── walk LEFT (earlier than period start) ─────────────────────────────────
|
||||||
left_cursor = start - _INTERVAL_DURATION
|
left_cursor = start - _INTERVAL_DURATION
|
||||||
left_additions = _walk_contiguous(interval_index, left_cursor, backward_step, primary_level, max_intervals)
|
left_additions = _walk_contiguous(interval_index, left_cursor, backward_step, primary_level, max_intervals)
|
||||||
|
left_used_fallback = False
|
||||||
if not left_additions:
|
if not left_additions:
|
||||||
# Fallback: no primary-level neighbours on this side → try fallback level
|
# Fallback: only if the period interior is not predominantly primary_level.
|
||||||
left_additions = _walk_contiguous(interval_index, left_cursor, backward_step, fallback_level, max_intervals)
|
# 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) ────────────────────────────────────
|
# ── walk RIGHT (later than period end) ────────────────────────────────────
|
||||||
right_additions = _walk_contiguous(interval_index, end, forward_step, primary_level, max_intervals)
|
right_additions = _walk_contiguous(interval_index, end, forward_step, primary_level, max_intervals)
|
||||||
|
right_used_fallback = False
|
||||||
if not right_additions:
|
if not right_additions:
|
||||||
# Fallback: no primary-level neighbours on this side → try fallback level
|
# Fallback: same majority gate as left side.
|
||||||
right_additions = _walk_contiguous(interval_index, end, forward_step, fallback_level, max_intervals)
|
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)
|
total_added = len(left_additions) + len(right_additions)
|
||||||
if total_added == 0:
|
if total_added == 0:
|
||||||
return period
|
return period
|
||||||
|
|
||||||
# ── rebuild full interval list for the extended 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
|
all_period_intervals = left_additions + original_intervals + right_additions
|
||||||
|
|
||||||
# ── recalculate boundaries ────────────────────────────────────────────────
|
# ── recalculate boundaries ────────────────────────────────────────────────
|
||||||
|
|
@ -256,7 +345,6 @@ def _extend_period_edges(
|
||||||
cv_pct = round(statistics.stdev(prices_for_vol) / mean_p * 100, 1)
|
cv_pct = round(statistics.stdev(prices_for_vol) / mean_p * 100, 1)
|
||||||
|
|
||||||
# ── assemble updated period dict (keep structural fields, update statistics) ─
|
# ── assemble updated period dict (keep structural fields, update statistics) ─
|
||||||
reverse_sort = primary_level == PRICE_LEVEL_VERY_EXPENSIVE
|
|
||||||
updated: dict[str, Any] = {
|
updated: dict[str, Any] = {
|
||||||
**period,
|
**period,
|
||||||
# Time fields
|
# Time fields
|
||||||
|
|
|
||||||
|
|
@ -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
|
# 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_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_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
|
# Cross-Day Supersession: When tomorrow data arrives, late-night periods that are
|
||||||
# worse than early-morning tomorrow periods become obsolete
|
# 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_VALLEY = "valley" # Single price minimum (U/V-shape)
|
||||||
DAY_PATTERN_PEAK = "peak" # Single price maximum (Λ-shape)
|
DAY_PATTERN_PEAK = "peak" # Single price maximum (Λ-shape)
|
||||||
DAY_PATTERN_DOUBLE_VALLEY = "double_valley" # Two minima, W-shape
|
DAY_PATTERN_DOUBLE_DIP = "double_dip" # Two minima, W-shape
|
||||||
DAY_PATTERN_DOUBLE_PEAK = "double_peak" # Two peaks, M-shape
|
DAY_PATTERN_DUCK_CURVE = "duck_curve" # Two peaks with midday valley (solar duck curve)
|
||||||
DAY_PATTERN_FLAT = "flat" # No significant variation
|
DAY_PATTERN_FLAT = "flat" # No significant variation
|
||||||
DAY_PATTERN_RISING = "rising" # Persistently rising throughout the day
|
DAY_PATTERN_RISING = "rising" # Persistently rising throughout the day
|
||||||
DAY_PATTERN_FALLING = "falling" # Persistently falling 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] = [
|
ALL_DAY_PATTERNS: list[str] = [
|
||||||
DAY_PATTERN_VALLEY,
|
DAY_PATTERN_VALLEY,
|
||||||
DAY_PATTERN_PEAK,
|
DAY_PATTERN_PEAK,
|
||||||
DAY_PATTERN_DOUBLE_VALLEY,
|
DAY_PATTERN_DOUBLE_DIP,
|
||||||
DAY_PATTERN_DOUBLE_PEAK,
|
DAY_PATTERN_DUCK_CURVE,
|
||||||
DAY_PATTERN_FLAT,
|
DAY_PATTERN_FLAT,
|
||||||
DAY_PATTERN_RISING,
|
DAY_PATTERN_RISING,
|
||||||
DAY_PATTERN_FALLING,
|
DAY_PATTERN_FALLING,
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,15 @@
|
||||||
{
|
{
|
||||||
"domain": "tibber_prices",
|
"domain": "tibber_prices",
|
||||||
"name": "Tibber Price Information & Ratings",
|
"name": "Tibber Price Information & Ratings",
|
||||||
"codeowners": ["@jpawlowski"],
|
"codeowners": [
|
||||||
|
"@jpawlowski"
|
||||||
|
],
|
||||||
"config_flow": true,
|
"config_flow": true,
|
||||||
"documentation": "https://github.com/jpawlowski/hass.tibber_prices",
|
"documentation": "https://github.com/jpawlowski/hass.tibber_prices",
|
||||||
"iot_class": "cloud_polling",
|
"iot_class": "cloud_polling",
|
||||||
"issue_tracker": "https://github.com/jpawlowski/hass.tibber_prices/issues",
|
"issue_tracker": "https://github.com/jpawlowski/hass.tibber_prices/issues",
|
||||||
"requirements": ["aiofiles>=23.2.1"],
|
"requirements": [
|
||||||
"version": "0.31.0b2"
|
"aiofiles>=23.2.1"
|
||||||
|
],
|
||||||
|
"version": "0.31.0b3"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -999,8 +999,8 @@ DAY_PATTERN_SENSORS = (
|
||||||
options=[
|
options=[
|
||||||
"valley",
|
"valley",
|
||||||
"peak",
|
"peak",
|
||||||
"double_valley",
|
"double_dip",
|
||||||
"double_peak",
|
"duck_curve",
|
||||||
"flat",
|
"flat",
|
||||||
"rising",
|
"rising",
|
||||||
"falling",
|
"falling",
|
||||||
|
|
@ -1017,8 +1017,8 @@ DAY_PATTERN_SENSORS = (
|
||||||
options=[
|
options=[
|
||||||
"valley",
|
"valley",
|
||||||
"peak",
|
"peak",
|
||||||
"double_valley",
|
"double_dip",
|
||||||
"double_peak",
|
"duck_curve",
|
||||||
"flat",
|
"flat",
|
||||||
"rising",
|
"rising",
|
||||||
"falling",
|
"falling",
|
||||||
|
|
@ -1035,8 +1035,8 @@ DAY_PATTERN_SENSORS = (
|
||||||
options=[
|
options=[
|
||||||
"valley",
|
"valley",
|
||||||
"peak",
|
"peak",
|
||||||
"double_valley",
|
"double_dip",
|
||||||
"double_peak",
|
"duck_curve",
|
||||||
"flat",
|
"flat",
|
||||||
"rising",
|
"rising",
|
||||||
"falling",
|
"falling",
|
||||||
|
|
|
||||||
|
|
@ -978,8 +978,8 @@
|
||||||
"state": {
|
"state": {
|
||||||
"valley": "Tal",
|
"valley": "Tal",
|
||||||
"peak": "Gipfel",
|
"peak": "Gipfel",
|
||||||
"double_valley": "Doppeltal",
|
"double_dip": "Doppelmulde",
|
||||||
"double_peak": "Doppelgipfel",
|
"duck_curve": "Entenkurve",
|
||||||
"flat": "Flach",
|
"flat": "Flach",
|
||||||
"rising": "Steigend",
|
"rising": "Steigend",
|
||||||
"falling": "Fallend",
|
"falling": "Fallend",
|
||||||
|
|
@ -991,8 +991,8 @@
|
||||||
"state": {
|
"state": {
|
||||||
"valley": "Tal",
|
"valley": "Tal",
|
||||||
"peak": "Gipfel",
|
"peak": "Gipfel",
|
||||||
"double_valley": "Doppeltal",
|
"double_dip": "Doppelmulde",
|
||||||
"double_peak": "Doppelgipfel",
|
"duck_curve": "Entenkurve",
|
||||||
"flat": "Flach",
|
"flat": "Flach",
|
||||||
"rising": "Steigend",
|
"rising": "Steigend",
|
||||||
"falling": "Fallend",
|
"falling": "Fallend",
|
||||||
|
|
@ -1004,8 +1004,8 @@
|
||||||
"state": {
|
"state": {
|
||||||
"valley": "Tal",
|
"valley": "Tal",
|
||||||
"peak": "Gipfel",
|
"peak": "Gipfel",
|
||||||
"double_valley": "Doppeltal",
|
"double_dip": "Doppelmulde",
|
||||||
"double_peak": "Doppelgipfel",
|
"duck_curve": "Entenkurve",
|
||||||
"flat": "Flach",
|
"flat": "Flach",
|
||||||
"rising": "Steigend",
|
"rising": "Steigend",
|
||||||
"falling": "Fallend",
|
"falling": "Fallend",
|
||||||
|
|
|
||||||
|
|
@ -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_extend_to_very_cheap": "When enabled, detected best price periods expand outward to absorb adjacent intervals with a 'Very cheap' price level. This widens low-price windows to better capture extremely cheap intervals at the edges of detected periods.",
|
||||||
"best_price_max_extension_intervals": "Maximum number of additional intervals to absorb per side (left and right edge). Each interval is 15 minutes. Example: 4 intervals = up to 1 hour extension per edge. Default: 4",
|
"best_price_max_extension_intervals": "Maximum number of additional intervals to absorb per side (left and right edge). Each interval is 15 minutes. Example: 4 intervals = up to 1 hour extension per edge. Default: 4",
|
||||||
"best_price_geometric_flex": "Extra flex percentage applied to intervals that fall inside a detected price valley (V-shape). When a valley pattern is detected for the day, intervals within the valley zone get this additional tolerance, making the period detector more likely to include them. 0 = disabled. Default: 0",
|
"best_price_geometric_flex": "Extra flex percentage applied to intervals that fall inside a detected price valley (V-shape). When a valley pattern is detected for the day, intervals within the valley zone get this additional tolerance, making the period detector more likely to include them. 0 = disabled. Default: 0",
|
||||||
"best_price_segment_forcing": "When enabled, days with a W-shaped price curve (two valleys separated by a central peak) split at the central peak. Period detection runs independently for each valley side, ensuring each valley has the required number of periods. This prevents both periods from clustering in the same valley. Requires the day pattern sensor to detect a 'double_valley' pattern.",
|
"best_price_segment_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"
|
"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_extend_to_very_expensive": "When enabled, detected peak price periods expand outward to absorb adjacent intervals with a 'Very expensive' price level. This widens high-price windows to better capture extremely expensive intervals at the edges of detected periods.",
|
||||||
"peak_price_max_extension_intervals": "Maximum number of additional intervals to absorb per side (left and right edge). Each interval is 15 minutes. Example: 4 intervals = up to 1 hour extension per edge. Default: 4",
|
"peak_price_max_extension_intervals": "Maximum number of additional intervals to absorb per side (left and right edge). Each interval is 15 minutes. Example: 4 intervals = up to 1 hour extension per edge. Default: 4",
|
||||||
"peak_price_geometric_flex": "Extra flex percentage applied to intervals that fall inside a detected price peak (Λ-shape). When a peak pattern is detected for the day, intervals within the peak zone get this additional tolerance, making the period detector more likely to include them. 0 = disabled. Default: 0",
|
"peak_price_geometric_flex": "Extra flex percentage applied to intervals that fall inside a detected price peak (Λ-shape). When a peak pattern is detected for the day, intervals within the peak zone get this additional tolerance, making the period detector more likely to include them. 0 = disabled. Default: 0",
|
||||||
"peak_price_segment_forcing": "When enabled, days with an M-shaped price curve (two peaks separated by a central valley) split at the central valley. Period detection runs independently for each peak side, ensuring each peak has the required number of periods. This prevents both periods from clustering in the same peak. Requires the day pattern sensor to detect a 'double_peak' pattern.",
|
"peak_price_segment_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"
|
"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": {
|
"state": {
|
||||||
"valley": "Valley",
|
"valley": "Valley",
|
||||||
"peak": "Peak",
|
"peak": "Peak",
|
||||||
"double_valley": "Double Valley",
|
"double_dip": "Double Dip",
|
||||||
"double_peak": "Double Peak",
|
"duck_curve": "Duck Curve",
|
||||||
"flat": "Flat",
|
"flat": "Flat",
|
||||||
"rising": "Rising",
|
"rising": "Rising",
|
||||||
"falling": "Falling",
|
"falling": "Falling",
|
||||||
|
|
@ -991,8 +991,8 @@
|
||||||
"state": {
|
"state": {
|
||||||
"valley": "Valley",
|
"valley": "Valley",
|
||||||
"peak": "Peak",
|
"peak": "Peak",
|
||||||
"double_valley": "Double Valley",
|
"double_dip": "Double Dip",
|
||||||
"double_peak": "Double Peak",
|
"duck_curve": "Duck Curve",
|
||||||
"flat": "Flat",
|
"flat": "Flat",
|
||||||
"rising": "Rising",
|
"rising": "Rising",
|
||||||
"falling": "Falling",
|
"falling": "Falling",
|
||||||
|
|
@ -1004,8 +1004,8 @@
|
||||||
"state": {
|
"state": {
|
||||||
"valley": "Valley",
|
"valley": "Valley",
|
||||||
"peak": "Peak",
|
"peak": "Peak",
|
||||||
"double_valley": "Double Valley",
|
"double_dip": "Double Dip",
|
||||||
"double_peak": "Double Peak",
|
"duck_curve": "Duck Curve",
|
||||||
"flat": "Flat",
|
"flat": "Flat",
|
||||||
"rising": "Rising",
|
"rising": "Rising",
|
||||||
"falling": "Falling",
|
"falling": "Falling",
|
||||||
|
|
|
||||||
|
|
@ -978,8 +978,8 @@
|
||||||
"state": {
|
"state": {
|
||||||
"valley": "Dal",
|
"valley": "Dal",
|
||||||
"peak": "Topp",
|
"peak": "Topp",
|
||||||
"double_valley": "Dobbel dal",
|
"double_dip": "Dobbel bunn",
|
||||||
"double_peak": "Dobbel topp",
|
"duck_curve": "Andekurve",
|
||||||
"flat": "Flat",
|
"flat": "Flat",
|
||||||
"rising": "Stigende",
|
"rising": "Stigende",
|
||||||
"falling": "Fallende",
|
"falling": "Fallende",
|
||||||
|
|
@ -991,8 +991,8 @@
|
||||||
"state": {
|
"state": {
|
||||||
"valley": "Dal",
|
"valley": "Dal",
|
||||||
"peak": "Topp",
|
"peak": "Topp",
|
||||||
"double_valley": "Dobbel dal",
|
"double_dip": "Dobbel bunn",
|
||||||
"double_peak": "Dobbel topp",
|
"duck_curve": "Andekurve",
|
||||||
"flat": "Flat",
|
"flat": "Flat",
|
||||||
"rising": "Stigende",
|
"rising": "Stigende",
|
||||||
"falling": "Fallende",
|
"falling": "Fallende",
|
||||||
|
|
@ -1004,8 +1004,8 @@
|
||||||
"state": {
|
"state": {
|
||||||
"valley": "Dal",
|
"valley": "Dal",
|
||||||
"peak": "Topp",
|
"peak": "Topp",
|
||||||
"double_valley": "Dobbel dal",
|
"double_dip": "Dobbel bunn",
|
||||||
"double_peak": "Dobbel topp",
|
"duck_curve": "Andekurve",
|
||||||
"flat": "Flat",
|
"flat": "Flat",
|
||||||
"rising": "Stigende",
|
"rising": "Stigende",
|
||||||
"falling": "Fallende",
|
"falling": "Fallende",
|
||||||
|
|
|
||||||
|
|
@ -978,8 +978,8 @@
|
||||||
"state": {
|
"state": {
|
||||||
"valley": "Dal",
|
"valley": "Dal",
|
||||||
"peak": "Piek",
|
"peak": "Piek",
|
||||||
"double_valley": "Dubbel Dal",
|
"double_dip": "Dubbele Dip",
|
||||||
"double_peak": "Dubbele Piek",
|
"duck_curve": "Eendcurve",
|
||||||
"flat": "Vlak",
|
"flat": "Vlak",
|
||||||
"rising": "Stijgend",
|
"rising": "Stijgend",
|
||||||
"falling": "Dalend",
|
"falling": "Dalend",
|
||||||
|
|
@ -991,8 +991,8 @@
|
||||||
"state": {
|
"state": {
|
||||||
"valley": "Dal",
|
"valley": "Dal",
|
||||||
"peak": "Piek",
|
"peak": "Piek",
|
||||||
"double_valley": "Dubbel Dal",
|
"double_dip": "Dubbele Dip",
|
||||||
"double_peak": "Dubbele Piek",
|
"duck_curve": "Eendcurve",
|
||||||
"flat": "Vlak",
|
"flat": "Vlak",
|
||||||
"rising": "Stijgend",
|
"rising": "Stijgend",
|
||||||
"falling": "Dalend",
|
"falling": "Dalend",
|
||||||
|
|
@ -1004,8 +1004,8 @@
|
||||||
"state": {
|
"state": {
|
||||||
"valley": "Dal",
|
"valley": "Dal",
|
||||||
"peak": "Piek",
|
"peak": "Piek",
|
||||||
"double_valley": "Dubbel Dal",
|
"double_dip": "Dubbele Dip",
|
||||||
"double_peak": "Dubbele Piek",
|
"duck_curve": "Eendcurve",
|
||||||
"flat": "Vlak",
|
"flat": "Vlak",
|
||||||
"rising": "Stijgend",
|
"rising": "Stijgend",
|
||||||
"falling": "Dalend",
|
"falling": "Dalend",
|
||||||
|
|
|
||||||
|
|
@ -978,8 +978,8 @@
|
||||||
"state": {
|
"state": {
|
||||||
"valley": "Dal",
|
"valley": "Dal",
|
||||||
"peak": "Topp",
|
"peak": "Topp",
|
||||||
"double_valley": "Dubbeldal",
|
"double_dip": "Dubbel dipp",
|
||||||
"double_peak": "Dubbeltopp",
|
"duck_curve": "Ankkurva",
|
||||||
"flat": "Flat",
|
"flat": "Flat",
|
||||||
"rising": "Stigande",
|
"rising": "Stigande",
|
||||||
"falling": "Fallande",
|
"falling": "Fallande",
|
||||||
|
|
@ -991,8 +991,8 @@
|
||||||
"state": {
|
"state": {
|
||||||
"valley": "Dal",
|
"valley": "Dal",
|
||||||
"peak": "Topp",
|
"peak": "Topp",
|
||||||
"double_valley": "Dubbeldal",
|
"double_dip": "Dubbel dipp",
|
||||||
"double_peak": "Dubbeltopp",
|
"duck_curve": "Ankkurva",
|
||||||
"flat": "Flat",
|
"flat": "Flat",
|
||||||
"rising": "Stigande",
|
"rising": "Stigande",
|
||||||
"falling": "Fallande",
|
"falling": "Fallande",
|
||||||
|
|
@ -1004,8 +1004,8 @@
|
||||||
"state": {
|
"state": {
|
||||||
"valley": "Dal",
|
"valley": "Dal",
|
||||||
"peak": "Topp",
|
"peak": "Topp",
|
||||||
"double_valley": "Dubbeldal",
|
"double_dip": "Dubbel dipp",
|
||||||
"double_peak": "Dubbeltopp",
|
"duck_curve": "Ankkurva",
|
||||||
"flat": "Flat",
|
"flat": "Flat",
|
||||||
"rising": "Stigande",
|
"rising": "Stigande",
|
||||||
"falling": "Fallande",
|
"falling": "Fallande",
|
||||||
|
|
|
||||||
|
|
@ -375,13 +375,8 @@ Then sections using ### (H3 headings):
|
||||||
|
|
||||||
Skip any section that has no content.
|
Skip any section that has no content.
|
||||||
|
|
||||||
After the last section, ALWAYS end with this exact footer, no modifications:
|
Do NOT add any footer or sign-off. End your response after the last content section.
|
||||||
|
A footer is appended automatically after your response.
|
||||||
---
|
|
||||||
|
|
||||||
If this release saved you some money on your electricity bill, a coffee would be much appreciated! ☕
|
|
||||||
|
|
||||||
[](https://www.buymeacoffee.com/jpawlowski)
|
|
||||||
|
|
||||||
## TITLE SELECTION
|
## TITLE SELECTION
|
||||||
- Find the most user-impactful change: new sensors > bug fixes affecting data quality > reliability improvements > UI improvements > translations
|
- 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"
|
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! ☕
|
||||||
|
|
||||||
|
[](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)
|
# Backend: git-cliff (template-based)
|
||||||
generate_with_gitcliff() {
|
generate_with_gitcliff() {
|
||||||
log_info "${BLUE}==> Generating with git-cliff${NC}"
|
log_info "${BLUE}==> Generating with git-cliff${NC}"
|
||||||
|
|
@ -829,26 +878,26 @@ if [ "$AUTO_UPDATE_AVAILABLE" = "true" ]; then
|
||||||
TEMP_NOTES=$(mktemp)
|
TEMP_NOTES=$(mktemp)
|
||||||
case "$BACKEND" in
|
case "$BACKEND" in
|
||||||
copilot)
|
copilot)
|
||||||
generate_with_copilot >"$TEMP_NOTES"
|
generate_with_copilot | ensure_canonical_footer >"$TEMP_NOTES"
|
||||||
;;
|
;;
|
||||||
git-cliff)
|
git-cliff)
|
||||||
generate_with_gitcliff >"$TEMP_NOTES"
|
generate_with_gitcliff | ensure_canonical_footer >"$TEMP_NOTES"
|
||||||
;;
|
;;
|
||||||
manual)
|
manual)
|
||||||
generate_with_manual >"$TEMP_NOTES"
|
generate_with_manual | ensure_canonical_footer >"$TEMP_NOTES"
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
else
|
else
|
||||||
# No auto-update, just output to stdout
|
# No auto-update, just output to stdout
|
||||||
case "$BACKEND" in
|
case "$BACKEND" in
|
||||||
copilot)
|
copilot)
|
||||||
generate_with_copilot
|
generate_with_copilot | ensure_canonical_footer
|
||||||
;;
|
;;
|
||||||
git-cliff)
|
git-cliff)
|
||||||
generate_with_gitcliff
|
generate_with_gitcliff | ensure_canonical_footer
|
||||||
;;
|
;;
|
||||||
manual)
|
manual)
|
||||||
generate_with_manual
|
generate_with_manual | ensure_canonical_footer
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue