feat(sensors): add day pattern detection sensors (valley/peak/flat/rising/falling)

Introduces a new day_pattern.py module that analyses the 15-min price curve
for each calendar day (yesterday/today/tomorrow) and classifies its shape.

New sensors:
  day_pattern_yesterday / day_pattern_today / day_pattern_tomorrow
  EntityCategory.DIAGNOSTIC, SensorDeviceClass.ENUM

Patterns: valley, peak, double_valley, double_peak, flat, rising, falling, mixed

The detector uses centred-rolling smoothing, prominence-filtered extrema,
Kneedle-based knee detection, and monotone segment building.
Coordinator populates transformed_data["dayPatterns"] after priceInfo enrichment.

Impact: Users can trigger automations based on the shape of the day's price
curve, e.g. pre-heat when tomorrow is a valley day.
This commit is contained in:
Julian Pawlowski 2026-04-11 21:07:16 +00:00
parent 999ecd358f
commit 447dc907e6
20 changed files with 1123 additions and 4 deletions

View file

@ -7,6 +7,7 @@ import logging
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
from custom_components.tibber_prices import const as _const from custom_components.tibber_prices import const as _const
from custom_components.tibber_prices.coordinator.period_handlers.day_pattern import detect_day_patterns
from custom_components.tibber_prices.utils.price import enrich_price_info_with_differences from custom_components.tibber_prices.utils.price import enrich_price_info_with_differences
if TYPE_CHECKING: if TYPE_CHECKING:
@ -274,6 +275,12 @@ class TibberPricesDataTransformer:
if "priceInfo" in transformed_data: if "priceInfo" in transformed_data:
transformed_data["pricePeriods"] = self._calculate_periods_fn(transformed_data["priceInfo"]) transformed_data["pricePeriods"] = self._calculate_periods_fn(transformed_data["priceInfo"])
# Detect day patterns (yesterday / today / tomorrow)
transformed_data["dayPatterns"] = detect_day_patterns(
transformed_data["priceInfo"],
time=self.time,
)
# Cache the transformed data # Cache the transformed data
self._cached_transformed_data = transformed_data self._cached_transformed_data = transformed_data
self._last_transformation_config = self._get_current_transformation_config() self._last_transformation_config = self._get_current_transformation_config()

View file

@ -19,6 +19,9 @@ from __future__ import annotations
# Re-export main API functions # Re-export main API functions
from .core import calculate_periods from .core import calculate_periods
# Re-export day pattern detection
from .day_pattern import detect_day_patterns
# Re-export outlier filtering # Re-export outlier filtering
from .outlier_filtering import filter_price_outliers from .outlier_filtering import filter_price_outliers
@ -27,12 +30,23 @@ from .relaxation import calculate_periods_with_relaxation
# Re-export constants and types # Re-export constants and types
from .types import ( from .types import (
ALL_DAY_PATTERNS,
DAY_PATTERN_DOUBLE_PEAK,
DAY_PATTERN_DOUBLE_VALLEY,
DAY_PATTERN_FALLING,
DAY_PATTERN_FLAT,
DAY_PATTERN_MIXED,
DAY_PATTERN_PEAK,
DAY_PATTERN_RISING,
DAY_PATTERN_VALLEY,
INDENT_L0, INDENT_L0,
INDENT_L1, INDENT_L1,
INDENT_L2, INDENT_L2,
INDENT_L3, INDENT_L3,
INDENT_L4, INDENT_L4,
INDENT_L5, INDENT_L5,
DayPatternDict,
SegmentDict,
TibberPricesIntervalCriteria, TibberPricesIntervalCriteria,
TibberPricesPeriodConfig, TibberPricesPeriodConfig,
TibberPricesPeriodData, TibberPricesPeriodData,
@ -41,12 +55,23 @@ from .types import (
) )
__all__ = [ __all__ = [
"ALL_DAY_PATTERNS",
"DAY_PATTERN_DOUBLE_PEAK",
"DAY_PATTERN_DOUBLE_VALLEY",
"DAY_PATTERN_FALLING",
"DAY_PATTERN_FLAT",
"DAY_PATTERN_MIXED",
"DAY_PATTERN_PEAK",
"DAY_PATTERN_RISING",
"DAY_PATTERN_VALLEY",
"INDENT_L0", "INDENT_L0",
"INDENT_L1", "INDENT_L1",
"INDENT_L2", "INDENT_L2",
"INDENT_L3", "INDENT_L3",
"INDENT_L4", "INDENT_L4",
"INDENT_L5", "INDENT_L5",
"DayPatternDict",
"SegmentDict",
"TibberPricesIntervalCriteria", "TibberPricesIntervalCriteria",
"TibberPricesPeriodConfig", "TibberPricesPeriodConfig",
"TibberPricesPeriodData", "TibberPricesPeriodData",
@ -54,5 +79,6 @@ __all__ = [
"TibberPricesThresholdConfig", "TibberPricesThresholdConfig",
"calculate_periods", "calculate_periods",
"calculate_periods_with_relaxation", "calculate_periods_with_relaxation",
"detect_day_patterns",
"filter_price_outliers", "filter_price_outliers",
] ]

View file

@ -0,0 +1,577 @@
"""
Day price pattern detection for Tibber Prices.
Analyses quarter-hourly price intervals for a calendar day and classifies them
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)
FLAT - No significant variation (CV <= 10 %)
RISING - Monotonically / persistently rising
FALLING - Monotonically / persistently falling
MIXED - Multiple extrema that do not neatly fit above patterns
For VALLEY and PEAK the module also locates the *knee points* (left and right
inflection points of the flanks) using a simplified Kneedle algorithm so that
Phases 3+ can extend period boundaries geometrically.
Intra-day segments are surfaced as a list of consecutive region dicts, allowing
automations to query "is the current hour in a rising segment?".
All functions are pure (no side effects) and operate on already-enriched
interval dicts produced by utils/price.py.
"""
from __future__ import annotations
import logging
import math
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from datetime import date, datetime
from custom_components.tibber_prices.coordinator.time_service import TibberPricesTimeService
_LOGGER = logging.getLogger(__name__)
_LOGGER_DETAILS = logging.getLogger(__name__ + ".details")
# ─── constants ────────────────────────────────────────────────────────────────
# A day is considered "flat" if its coefficient of variation is below this value.
# Reuses the same threshold as relaxation.py (LOW_CV_FLAT_DAY_THRESHOLD = 10.0).
FLAT_CV_THRESHOLD = 10.0 # %
# Minimum amplitude an extremum must have to count as "significant".
# Defined as a fraction of the day's price span. 0.20 = 20 % of span.
MIN_EXTREMUM_AMPLITUDE_RATIO = 0.20
# Smoothing window (in 15-min intervals) for the rolling-average pre-filter.
SMOOTH_WINDOW = 4 # 4 x 15 min = 1 h
# Minimum intervals in a day to attempt pattern detection.
MIN_DAY_INTERVALS = 4
# Minimum intervals in a series to search for extrema.
MIN_EXTREMA_INTERVALS = 3
# Edge zone: relative position threshold for RISING / FALLING detection.
_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_FLAT = "flat"
DAY_PATTERN_RISING = "rising"
DAY_PATTERN_FALLING = "falling"
DAY_PATTERN_MIXED = "mixed"
# Segment type constants
SEGMENT_TYPE_RISING = "rising"
SEGMENT_TYPE_FALLING = "falling"
SEGMENT_TYPE_FLAT = "flat"
# ─── public API ───────────────────────────────────────────────────────────────
def detect_day_patterns(
all_prices: list[dict[str, Any]],
*,
time: TibberPricesTimeService,
) -> dict[str, dict[str, Any]]:
"""
Detect price patterns for yesterday, today, and tomorrow.
Groups enriched price intervals by calendar day and runs pattern detection
on each. Always returns all three keys; ``tomorrow`` may be ``None`` if
data is not yet available.
Args:
all_prices: Flat list of enriched price interval dicts (the same list
that ``coordinator.data["priceInfo"]`` holds).
time: TibberPricesTimeService (needed for timezone-aware date boundaries).
Returns:
``{"yesterday": <dict|None>, "today": <dict|None>, "tomorrow": <dict|None>}``
where each value is a ``DayPatternDict`` (see _detect_single_day_pattern).
"""
# ── group intervals by calendar day ────────────────────────────────────────
from .period_building import split_intervals_by_day # avoid circular at import time # noqa: PLC0415
intervals_by_day, _ = split_intervals_by_day(all_prices, time=time)
now = time.now()
today_date: date = now.date()
import datetime as _dt # noqa: PLC0415
yesterday_date = today_date - _dt.timedelta(days=1)
tomorrow_date = today_date + _dt.timedelta(days=1)
result: dict[str, dict[str, Any] | None] = {
"yesterday": None,
"today": None,
"tomorrow": None,
}
day_map: dict[str, date] = {
"yesterday": yesterday_date,
"today": today_date,
"tomorrow": tomorrow_date,
}
for label, date_key in day_map.items():
intervals = intervals_by_day.get(date_key)
if intervals and len(intervals) >= MIN_DAY_INTERVALS:
try:
result[label] = _detect_single_day_pattern(intervals, time=time)
except Exception:
_LOGGER.exception("Day pattern detection failed for %s (%s)", label, date_key)
result[label] = None
else:
result[label] = None
return result # type: ignore[return-value]
# ─── single-day detection ─────────────────────────────────────────────────────
def _detect_single_day_pattern(
intervals: list[dict[str, Any]],
*,
time: TibberPricesTimeService,
) -> dict[str, Any]:
"""
Analyse a single day's intervals and return a DayPatternDict.
The returned dict has the shape described in AGENTS.md (DayPatternDict).
"""
# Extract prices and datetimes (already tz-aware from enrichment)
prices_raw: list[float] = [float(iv["total"]) for iv in intervals]
times: list[datetime] = [time.get_interval_time(iv) for iv in intervals] # type: ignore[misc]
# ── coefficient of variation ────────────────────────────────────────────────
n = len(prices_raw)
mean_price = sum(prices_raw) / n
variance = sum((p - mean_price) ** 2 for p in prices_raw) / n
std_dev = math.sqrt(variance)
cv_pct = round((std_dev / abs(mean_price)) * 100, 1) if mean_price != 0 else 0.0
# ── smooth prices (1-h rolling average) ────────────────────────────────────
smoothed = _smooth_prices(prices_raw, window=SMOOTH_WINDOW)
# ── find significant extrema ────────────────────────────────────────────────
price_span = max(prices_raw) - min(prices_raw) if prices_raw else 0.0
extrema = _find_significant_extrema(smoothed, min_amplitude=price_span * MIN_EXTREMUM_AMPLITUDE_RATIO)
# ── classify pattern ────────────────────────────────────────────────────────
pattern, confidence = _classify_pattern(extrema, cv_pct, times)
# ── knee points + primary extreme time ─────────────────────────────────────
extreme_time: datetime | None = None
valley_start: datetime | None = None
valley_end: datetime | None = None
peak_start: datetime | None = None
peak_end: datetime | None = None
if pattern == DAY_PATTERN_VALLEY:
# Primary extreme = global minimum
min_idx = prices_raw.index(min(prices_raw))
extreme_time = times[min_idx] if min_idx < len(times) else None
lk, rk = _find_knee_points(smoothed, min_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
elif pattern == DAY_PATTERN_PEAK:
max_idx = prices_raw.index(max(prices_raw))
extreme_time = times[max_idx] if max_idx < len(times) else None
lk, rk = _find_knee_points(smoothed, max_idx)
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:
# 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:
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
# ── intra-day segments ──────────────────────────────────────────────────────
segments = _detect_segments(extrema, prices_raw, times)
result: dict[str, Any] = {
"pattern": pattern,
"confidence": round(confidence, 3),
"day_cv_percent": cv_pct,
"segments": segments,
"extreme_time": extreme_time,
"valley_start": valley_start,
"valley_end": valley_end,
"peak_start": peak_start,
"peak_end": peak_end,
}
_LOGGER_DETAILS.debug(
" Day pattern: %s (confidence=%.2f, cv=%.1f%%, extrema=%d, segments=%d)",
pattern,
confidence,
cv_pct,
len(extrema),
len(segments),
)
return result
# ─── smoothing ────────────────────────────────────────────────────────────────
def _smooth_prices(prices: list[float], window: int = SMOOTH_WINDOW) -> list[float]:
"""
Apply a centred rolling-average with the given window width.
Edge intervals use a narrower window (no zero-padding) so that pattern
detection at the start/end of the day is not distorted.
"""
n = len(prices)
half = window // 2
smoothed: list[float] = []
for i in range(n):
lo = max(0, i - half)
hi = min(n, i + half + 1)
smoothed.append(sum(prices[lo:hi]) / (hi - lo))
return smoothed
# ─── extrema detection ────────────────────────────────────────────────────────
def _find_significant_extrema(
smoothed: list[float],
*,
min_amplitude: float,
) -> list[dict[str, Any]]:
"""
Find local minima and maxima in the smoothed price series.
A local extremum is retained only if it exceeds *min_amplitude* above/below
both of its closest neighbours of the opposite polarity (prominence filter).
Returns a list of ``{"idx": int, "type": "min"|"max", "price": float}``
entries sorted by index.
"""
n = len(smoothed)
if n < MIN_EXTREMA_INTERVALS:
return []
# ── raw local extrema (strict local min/max) ────────────────────────────────
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]:
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]:
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).
significant: list[dict[str, Any]] = []
for cand in candidates:
idx = cand["idx"]
hw = max(4, n // 8) # neighbourhood half-width: ≥4 intervals, up to 1/8 of day
lo = max(0, idx - hw)
hi = min(n, idx + hw + 1)
neighbourhood = smoothed[lo:hi]
if cand["type"] == "min":
reference = sum(neighbourhood) / len(neighbourhood)
prominence = reference - cand["price"]
else:
reference = sum(neighbourhood) / len(neighbourhood)
prominence = cand["price"] - reference
if prominence >= min_amplitude * 0.8: # slight tolerance on the threshold
significant.append(cand)
# ── deduplicate: keep only the most extreme value between alternating types ──
return _deduplicate_extrema(significant)
def _deduplicate_extrema(extrema: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""
Ensure extrema alternate between min and max.
Between two consecutive minima (or two consecutive maxima), keep only the
more extreme one. This mirrors the classical definition of alternating
local extrema.
"""
if not extrema:
return []
result: list[dict[str, Any]] = [extrema[0]]
for e in extrema[1:]:
last = result[-1]
if e["type"] == last["type"]:
# Same type - keep the more extreme one
if e["type"] == "min":
if e["price"] < last["price"]:
result[-1] = e
elif e["price"] > last["price"]:
result[-1] = e
else:
result.append(e)
return result
# ─── pattern classification ───────────────────────────────────────────────────
def _classify_pattern( # noqa: PLR0911, PLR0912
extrema: list[dict[str, Any]],
cv_pct: float,
times: list[datetime],
) -> 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).
Returns:
(pattern_string, confidence_float)
"""
n_times = len(times)
# ── flat day ────────────────────────────────────────────────────────────────
if cv_pct <= FLAT_CV_THRESHOLD:
# Confidence scales with how flat it is relative to threshold
confidence = max(0.5, 1.0 - cv_pct / FLAT_CV_THRESHOLD)
return DAY_PATTERN_FLAT, confidence
# ── no significant extrema → monotone (rising or falling) ──────────────────
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.
return DAY_PATTERN_MIXED, 0.4
n_extrema = len(extrema)
types = [e["type"] for e in extrema]
# ── single extremum ─────────────────────────────────────────────────────────
if n_extrema == 1:
e = extrema[0]
# Check position: central extrema → stronger pattern
rel_pos = e["idx"] / max(1, n_times - 1)
centrality = 1.0 - abs(rel_pos - 0.5) * 2 # 0 at edges, 1 at centre
if e["type"] == "min":
confidence = 0.6 + 0.4 * centrality
return DAY_PATTERN_VALLEY, confidence
# max
# Check if it's edge-dominant: peak near start -> FALLING, near end -> RISING
if rel_pos < _EDGE_ZONE:
return DAY_PATTERN_FALLING, 0.6
if rel_pos > 1.0 - _EDGE_ZONE:
return DAY_PATTERN_RISING, 0.6
confidence = 0.6 + 0.4 * centrality
return DAY_PATTERN_PEAK, confidence
# ── two extrema ─────────────────────────────────────────────────────────────
if n_extrema == 2: # noqa: PLR2004
if types == ["max", "min"]:
return DAY_PATTERN_FALLING, 0.7
if types == ["min", "max"]:
return DAY_PATTERN_RISING, 0.7
if types == ["min", "min"]:
return DAY_PATTERN_DOUBLE_VALLEY, 0.65
if types == ["max", "max"]:
return DAY_PATTERN_DOUBLE_PEAK, 0.65
# ── three extrema ────────────────────────────────────────────────────────────
if n_extrema == 3: # noqa: PLR2004
# min-max-min → W-shape
if types == ["min", "max", "min"]:
return DAY_PATTERN_DOUBLE_VALLEY, 0.75
# max-min-max → M-shape
if types == ["max", "min", "max"]:
return DAY_PATTERN_DOUBLE_PEAK, 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
if types[0] == "max" and types[-1] == "min":
return DAY_PATTERN_FALLING, 0.55
# ── four or more extrema ─────────────────────────────────────────────────────
# Count dominating type
n_min = types.count("min")
n_max = types.count("max")
if abs(n_min - n_max) <= 1:
return DAY_PATTERN_MIXED, 0.5
# More minima: day is mostly cheap → loosely valley-ish
if n_min > n_max:
return DAY_PATTERN_MIXED, 0.45
return DAY_PATTERN_MIXED, 0.45
# ─── knee point detection (simplified Kneedle) ───────────────────────────────
def _find_knee_points(
smoothed: list[float],
extreme_idx: int,
) -> tuple[int | None, int | None]:
"""
Find the left and right knee points of a V-/Λ-shaped flank.
Uses a simplified Kneedle algorithm:
1. Normalise each flank to [0,1] on both axes.
2. Compute the perpendicular distance of each point from the straight line
connecting the flank start to the extreme point.
3. The knee is the point of maximum perpendicular distance.
Args:
smoothed: Smoothed price series for the full day.
extreme_idx: Index of the valley minimum (VALLEY) or peak maximum (PEAK).
is_minimum: True for valley (prices falling then rising),
False for peak (prices rising then falling).
Returns:
``(left_knee_idx, right_knee_idx)`` - indices into ``smoothed``.
Either may be ``None`` if the flank is too short.
"""
n = len(smoothed)
left_idx = _find_knee_on_flank(smoothed, start=0, end=extreme_idx)
right_idx = _find_knee_on_flank(smoothed, start=extreme_idx, end=n - 1)
return left_idx, right_idx
def _find_knee_on_flank(
prices: list[float],
start: int,
end: int,
) -> int | None:
"""
Locate the knee on one flank using the simplified Kneedle method.
Args:
prices: Full price series.
start: Index of flank start.
end: Index of flank end (the extreme point).
descending: True if prices fall from start end, False if they rise.
Returns:
Index of knee point, or ``None`` if flank is fewer than 4 intervals.
"""
length = end - start
if length < MIN_EXTREMA_INTERVALS:
return None
p_start = prices[start]
p_end = prices[end]
# Normalise so that start=(0,0) and end=(1,1)
px_range = float(length)
py_range = p_end - p_start
if abs(py_range) < 1e-9: # noqa: PLR2004
return None # Flat flank - no knee
max_dist = 0.0
knee_idx: int | None = None
for i in range(start + 1, end):
# Normalised coordinates
nx = (i - start) / px_range
ny = (prices[i] - p_start) / py_range
# For the line y=x: perpendicular distance = |ny - nx| / sqrt(2)
dist = abs(ny - nx) / math.sqrt(2)
if dist > max_dist:
max_dist = dist
knee_idx = i
return knee_idx
# ─── intra-day segment detection ─────────────────────────────────────────────
def _detect_segments(
extrema: list[dict[str, Any]],
prices: list[float],
times: list[datetime],
) -> list[dict[str, Any]]:
"""
Build a list of monotone segments separated by the detected extrema.
Each segment is a dict with:
type - "rising" | "falling" | "flat"
start - tz-aware datetime of first interval
end - tz-aware datetime of last interval
price_min - min price in segment (EUR/NOK/SEK)
price_max - max price in segment
price_mean - mean price in segment
"""
n = len(prices)
if n == 0:
return []
# Build boundary indices: 0, all extremum indices, n-1
boundaries = [0, *sorted(e["idx"] for e in extrema), n - 1]
# Deduplicate consecutive boundaries
boundaries = list(dict.fromkeys(boundaries)) # preserves order, removes dupes
segments: list[dict[str, Any]] = []
for seg_i in range(len(boundaries) - 1):
lo = boundaries[seg_i]
hi = boundaries[seg_i + 1]
if hi <= lo:
continue
seg_prices = prices[lo : hi + 1]
price_start = prices[lo]
price_end = prices[hi]
delta = price_end - price_start
span = max(seg_prices) - min(seg_prices)
if span < (max(prices) - min(prices)) * 0.05:
seg_type = SEGMENT_TYPE_FLAT
elif delta > 0:
seg_type = SEGMENT_TYPE_RISING
else:
seg_type = SEGMENT_TYPE_FALLING
seg: dict[str, Any] = {
"type": seg_type,
"start": times[lo].isoformat() if lo < len(times) and times[lo] is not None else None,
"end": times[hi].isoformat() if hi < len(times) and times[hi] is not None else None,
"price_min": round(min(seg_prices), 4),
"price_max": round(max(seg_prices), 4),
"price_mean": round(sum(seg_prices) / len(seg_prices), 4),
}
segments.append(seg)
return segments

View file

@ -2,7 +2,7 @@
from __future__ import annotations from __future__ import annotations
from typing import TYPE_CHECKING, NamedTuple from typing import TYPE_CHECKING, NamedTuple, TypedDict
if TYPE_CHECKING: if TYPE_CHECKING:
from datetime import datetime from datetime import datetime
@ -104,3 +104,60 @@ class TibberPricesIntervalCriteria(NamedTuple):
flex: float flex: float
min_distance_from_avg: float min_distance_from_avg: float
reverse_sort: bool reverse_sort: bool
# ─── Day pattern constants ─────────────────────────────────────────────────────
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_FLAT = "flat" # No significant variation
DAY_PATTERN_RISING = "rising" # Persistently rising throughout the day
DAY_PATTERN_FALLING = "falling" # Persistently falling throughout the day
DAY_PATTERN_MIXED = "mixed" # Multiple extrema with no clear pattern
# Ordered list used to populate SensorDeviceClass.ENUM options=
ALL_DAY_PATTERNS: list[str] = [
DAY_PATTERN_VALLEY,
DAY_PATTERN_PEAK,
DAY_PATTERN_DOUBLE_VALLEY,
DAY_PATTERN_DOUBLE_PEAK,
DAY_PATTERN_FLAT,
DAY_PATTERN_RISING,
DAY_PATTERN_FALLING,
DAY_PATTERN_MIXED,
]
# Segment type constants
DAY_SEGMENT_RISING = "rising"
DAY_SEGMENT_FALLING = "falling"
DAY_SEGMENT_FLAT = "flat"
# ─── Day pattern TypedDicts ────────────────────────────────────────────────────
class SegmentDict(TypedDict):
"""One monotone price segment within a calendar day."""
type: str # "rising" | "falling" | "flat"
start: str | None # ISO datetime of first interval in segment
end: str | None # ISO datetime of last interval in segment
price_min: float # Minimum price in segment
price_max: float # Maximum price in segment
price_mean: float # Mean price in segment
class DayPatternDict(TypedDict):
"""Detected price pattern for one calendar day."""
pattern: str # One of the DAY_PATTERN_* constants
confidence: float # 0.0 - 1.0
day_cv_percent: float # Coefficient of variation for the day (%)
segments: list[SegmentDict] # Monotone segments
extreme_time: str | None # ISO datetime of primary extremum (valley/peak)
valley_start: str | None # ISO datetime of left knee (VALLEY pattern only)
valley_end: str | None # ISO datetime of right knee (VALLEY pattern only)
peak_start: str | None # ISO datetime of left knee (PEAK pattern only)
peak_end: str | None # ISO datetime of right knee (PEAK pattern only)

View file

@ -486,6 +486,21 @@
"long_description": "Zeigt, ob dein Tibber-Abonnement derzeit aktiv ist, beendet wurde oder auf Aktivierung wartet. Ein Status 'Aktiv' bedeutet, dass du aktiv Strom über Tibber beziehst.", "long_description": "Zeigt, ob dein Tibber-Abonnement derzeit aktiv ist, beendet wurde oder auf Aktivierung wartet. Ein Status 'Aktiv' bedeutet, dass du aktiv Strom über Tibber beziehst.",
"usage_tips": "Nutze dies zur Überwachung deines Abonnementstatus. Richte Benachrichtigungen ein, wenn sich der Status von 'Aktiv' ändert, um einen unterbrechungsfreien Service sicherzustellen." "usage_tips": "Nutze dies zur Überwachung deines Abonnementstatus. Richte Benachrichtigungen ein, wenn sich der Status von 'Aktiv' ändert, um einen unterbrechungsfreien Service sicherzustellen."
}, },
"day_pattern_yesterday": {
"description": "Erkanntes Preismuster der gestrigen Strompreise",
"long_description": "Klassifiziert gestern in ein Preismuster: Tal (günstig in der Mitte), Gipfel (teuer in der Mitte), Doppeltal (zwei günstige Perioden), Doppelgipfel (zwei teure Perioden), Flach (geringe Variation), Steigend, Fallend oder Gemischt. Die Konfidenz- und CV-Attribute zeigen, wie verlässlich das Muster erkannt wurde.",
"usage_tips": "Nutze das gestrige Muster für Automationen: Ein Tal-Tag wiederholt sich oft am nächsten Tag und deutet darauf hin, dass Verbraucher auf die günstigen Mittagsstunden verschoben werden sollten."
},
"day_pattern_today": {
"description": "Erkanntes Preismuster der heutigen Strompreise",
"long_description": "Klassifiziert heute in ein Preismuster: Tal (günstig mittags), Gipfel (teuer mittags), Doppeltal (W-Form), Doppelgipfel (M-Form), Flach, Steigend, Fallend oder Gemischt. Attribute enthalten Konfidenz (01), Variationskoeffizient, Knickpunktzeiten und Tagessegmente.",
"usage_tips": "Nutze das Tagesmuster, um Verbraucher zu verschieben. Tal-Tag: Spülmaschine, Waschmaschine oder E-Auto-Laden in die günstige Mittagszeit legen. Gipfel-Tag: früh morgens oder spät abends waschen. Die Attribute valley_start und valley_end ermöglichen minutengenaue Automationen."
},
"day_pattern_tomorrow": {
"description": "Erkanntes Preismuster der morgigen Strompreise",
"long_description": "Klassifiziert morgen (sobald Daten verfügbar sind, typisch nach 13 Uhr) in ein Preismuster mit demselben Algorithmus wie heute. Die Attribute valley_start/valley_end oder peak_start/peak_end geben Knickpunktzeiten für das primäre Extremum an.",
"usage_tips": "Richte Abendautomationen ein, die das morgige Muster lesen und Wärmepumpe, Autolader oder Warmwasserbereiter für den nächsten Tag vorkonfigurieren. Kombiniere mit dem tomorrow_data_available Binärsensor."
},
"chart_data_export": { "chart_data_export": {
"description": "Datenexport für Dashboard-Integrationen", "description": "Datenexport für Dashboard-Integrationen",
"long_description": "Dieser Sensor ruft den get_chartdata-Service mit deiner konfigurierten YAML-Konfiguration auf und stellt das Ergebnis als Entity-Attribute bereit. Der Status zeigt 'ready' wenn Daten verfügbar sind, 'error' bei Fehlern, oder 'pending' vor dem ersten Aufruf. Perfekt für Dashboard-Integrationen wie ApexCharts, die Preisdaten aus Entity-Attributen lesen.", "long_description": "Dieser Sensor ruft den get_chartdata-Service mit deiner konfigurierten YAML-Konfiguration auf und stellt das Ergebnis als Entity-Attribute bereit. Der Status zeigt 'ready' wenn Daten verfügbar sind, 'error' bei Fehlern, oder 'pending' vor dem ersten Aufruf. Perfekt für Dashboard-Integrationen wie ApexCharts, die Preisdaten aus Entity-Attributen lesen.",

View file

@ -486,6 +486,21 @@
"long_description": "Shows whether your Tibber subscription is currently running, has ended, or is pending activation. A status of 'running' means you're actively receiving electricity through Tibber.", "long_description": "Shows whether your Tibber subscription is currently running, has ended, or is pending activation. A status of 'running' means you're actively receiving electricity through Tibber.",
"usage_tips": "Use this to monitor your subscription status. Set up alerts if status changes from 'running' to ensure uninterrupted service." "usage_tips": "Use this to monitor your subscription status. Set up alerts if status changes from 'running' to ensure uninterrupted service."
}, },
"day_pattern_yesterday": {
"description": "Detected price shape of yesterday's electricity prices",
"long_description": "Classifies yesterday into a price shape: Valley (cheap in the middle), Peak (expensive in the middle), Double Valley (two cheap periods), Double Peak (two expensive periods), Flat (little variation), Rising, Falling, or Mixed. The confidence and CV attributes indicate how reliably the pattern was detected.",
"usage_tips": "Use yesterday's pattern to refine automations: a Valley day often repeats the next day, suggesting you should pre-schedule cheap-hour loads. Pair with the confidence attribute to filter unreliable detections."
},
"day_pattern_today": {
"description": "Detected price shape of today's electricity prices",
"long_description": "Classifies today into a price shape: Valley (cheap in the middle of the day), Peak (expensive in the middle), Double Valley (W-shape, two cheap windows), Double Peak (M-shape, two expensive peaks), Flat (prices barely move), Rising (prices climb through the day), Falling (prices drop through the day), or Mixed. Attributes include confidence (0\u20131), coefficient of variation, knee-point times, and intra-day segments.",
"usage_tips": "Use today's pattern to decide when to shift loads. A Valley day means cheap prices around midday \u2014 ideal for running the dishwasher, washing machine, or charging the EV. A Peak day means expensive midday \u2014 run appliances early morning or late evening. Use valley_start and valley_end attributes to schedule automations precisely."
},
"day_pattern_tomorrow": {
"description": "Detected price shape of tomorrow's electricity prices",
"long_description": "Classifies tomorrow (once data is available, typically after 13:00) into a price shape using the same algorithm as today. The valley_start / valley_end or peak_start / peak_end attributes give knee-point times for the primary extremum so you can pre-schedule loads the evening before.",
"usage_tips": "Set up evening automations that read tomorrow's pattern and pre-configure heat pump schedules, car charging timers, or water heater settings for the following day. Pair with the tomorrow_data_available binary sensor to trigger the automation only when data is ready."
},
"chart_data_export": { "chart_data_export": {
"description": "Data export for dashboard integrations", "description": "Data export for dashboard integrations",
"long_description": "This binary sensor calls the get_chartdata service with your configured YAML parameters and exposes the result as entity attributes. The state is 'on' when the service call succeeds and data is available, 'off' when the call fails or no configuration is set. Perfect for dashboard integrations like ApexCharts that need to read price data from entity attributes.", "long_description": "This binary sensor calls the get_chartdata service with your configured YAML parameters and exposes the result as entity attributes. The state is 'on' when the service call succeeds and data is available, 'off' when the call fails or no configuration is set. Perfect for dashboard integrations like ApexCharts that need to read price data from entity attributes.",

View file

@ -486,6 +486,21 @@
"long_description": "Viser om Tibber-abonnementet ditt for øyeblikket er aktivt, avsluttet eller venter på aktivering. En status 'Aktiv' betyr at du aktivt mottar strøm gjennom Tibber.", "long_description": "Viser om Tibber-abonnementet ditt for øyeblikket er aktivt, avsluttet eller venter på aktivering. En status 'Aktiv' betyr at du aktivt mottar strøm gjennom Tibber.",
"usage_tips": "Bruk dette til å overvåke abonnementsstatusen din. Sett opp varsler hvis statusen endres fra 'Aktiv' for å sikre uavbrutt tjeneste." "usage_tips": "Bruk dette til å overvåke abonnementsstatusen din. Sett opp varsler hvis statusen endres fra 'Aktiv' for å sikre uavbrutt tjeneste."
}, },
"day_pattern_yesterday": {
"description": "Oppdaget prismønster for gårsdagens strømpriser",
"long_description": "Klassifiserer i går i et prismønster: Dal (billig midt på dagen), Topp (dyrt midt på dagen), Dobbel dal (to billige perioder), Dobbel topp (to dyre perioder), Flat (liten variasjon), Stigende, Fallende eller Blandet. Konfidensen og CV-attributtene viser hvor pålitelig mønsteret ble oppdaget.",
"usage_tips": "Bruk gårsdagens mønster til å forbedre automatikaene dine: et Dalmønster gjentar seg ofte neste dag og antyder at du bør forhåndsplanlegge billige middagstimer."
},
"day_pattern_today": {
"description": "Oppdaget prismønster for dagens strømpriser",
"long_description": "Klassifiserer i dag i et prismønster: Dal (billig midt på dagen), Topp (dyrt midt på dagen), Dobbel dal (W-form), Dobbel topp (M-form), Flat, Stigende, Fallende eller Blandet. Attributter inkluderer konfidensverdi (01), variasjonskoeffisient, knepunktstider og dagsegmenter.",
"usage_tips": "Bruk dagens mønster til å flytte forbruk. Daldag: kjør oppvaskmaskin, vaskemaskin eller lad elbilen rundt billige middagstimer. Toppdag: kjør apparater tidlig morgen eller sent kveld. Bruk valley_start og valley_end for presise automatikaer."
},
"day_pattern_tomorrow": {
"description": "Oppdaget prismønster for morgendagens strømpriser",
"long_description": "Klassifiserer i morgen (når data er tilgjengelig, typisk etter kl. 13) i et prismønster med samme algoritme som i dag. Attributtene valley_start/valley_end eller peak_start/peak_end gir knepunktstider.",
"usage_tips": "Sett opp kveldsautomasjonar som leser morgendagens mønster og forhåndskonfigurerer varmepumpe, billader eller varmtvannsberedere. Kombiner med tomorrow_data_available-binærsensoren."
},
"chart_data_export": { "chart_data_export": {
"description": "Dataeksport for dashboardintegrasjoner", "description": "Dataeksport for dashboardintegrasjoner",
"long_description": "Denne sensoren kaller get_chartdata-tjenesten med din konfigurerte YAML-konfigurasjon og eksponerer resultatet som entitetsattributter. Status viser 'ready' når data er tilgjengelig, 'error' ved feil, eller 'pending' før første kall. Perfekt for dashboardintegrasjoner som ApexCharts som trenger å lese prisdata fra entitetsattributter.", "long_description": "Denne sensoren kaller get_chartdata-tjenesten med din konfigurerte YAML-konfigurasjon og eksponerer resultatet som entitetsattributter. Status viser 'ready' når data er tilgjengelig, 'error' ved feil, eller 'pending' før første kall. Perfekt for dashboardintegrasjoner som ApexCharts som trenger å lese prisdata fra entitetsattributter.",

View file

@ -486,6 +486,21 @@
"long_description": "Geeft aan of je Tibber-abonnement momenteel actief is, beëindigd of wacht op activering. Een 'Actief'-status betekent dat je actief elektriciteit via Tibber afneemt.", "long_description": "Geeft aan of je Tibber-abonnement momenteel actief is, beëindigd of wacht op activering. Een 'Actief'-status betekent dat je actief elektriciteit via Tibber afneemt.",
"usage_tips": "Gebruik dit om je abonnementsstatus te monitoren. Stel meldingen in als de status verandert van 'Actief' om ononderbroken service te waarborgen." "usage_tips": "Gebruik dit om je abonnementsstatus te monitoren. Stel meldingen in als de status verandert van 'Actief' om ononderbroken service te waarborgen."
}, },
"day_pattern_yesterday": {
"description": "Gedetecteerd prijspatroon van gisterens elektriciteitsprijzen",
"long_description": "Classificeert gisteren in een prijspatroon: Dal (goedkoop in het midden), Piek (duur in het midden), Dubbel Dal (twee goedkope perioden), Dubbele Piek (twee dure perioden), Vlak (weinig variatie), Stijgend, Dalend of Gemengd. De confidence- en CV-attributen tonen hoe betrouwbaar het patroon is gedetecteerd.",
"usage_tips": "Gebruik het patroon van gisteren om automations te verfijnen: een Daldag herhaalt zich vaak de volgende dag en suggereert om goedkope middaguren in te plannen."
},
"day_pattern_today": {
"description": "Gedetecteerd prijspatroon van de huidige elektriciteitsprijzen",
"long_description": "Classificeert vandaag in een prijspatroon: Dal (goedkoop 's middags), Piek (duur 's middags), Dubbel Dal (W-vorm), Dubbele Piek (M-vorm), Vlak, Stijgend, Dalend of Gemengd. Attributen omvatten confidence (01), variatiecoëfficiënt, kniepunttijden en dagsegmenten.",
"usage_tips": "Gebruik het dagpatroon om verbruik te verschuiven. Daldag: draai vaatwasser, wasmachine of laad de EV 's middags. Piekdag: gebruik apparaten vroeg in de ochtend of laat in de avond. Gebruik valley_start en valley_end voor precieze automations."
},
"day_pattern_tomorrow": {
"description": "Gedetecteerd prijspatroon van de elektriciteitsprijzen van morgen",
"long_description": "Classificeert morgen (zodra data beschikbaar is, doorgaans na 13:00) in een prijspatroon met hetzelfde algoritme als vandaag. De attributen valley_start/valley_end of peak_start/peak_end geven kniepunttijden voor het primaire extremum.",
"usage_tips": "Stel avondautomations in die het patroon van morgen lezen en warmtepomp, autolader of boiler vooraf configureren. Combineer met de tomorrow_data_available binaire sensor."
},
"chart_data_export": { "chart_data_export": {
"description": "Data-export voor dashboard-integraties", "description": "Data-export voor dashboard-integraties",
"long_description": "Deze sensor roept de get_chartdata-service aan met jouw geconfigureerde YAML-configuratie en stelt het resultaat beschikbaar als entiteitsattributen. De status toont 'ready' wanneer data beschikbaar is, 'error' bij fouten, of 'pending' voor de eerste aanroep. Perfekt voor dashboard-integraties zoals ApexCharts die prijsgegevens uit entiteitsattributen moeten lezen.", "long_description": "Deze sensor roept de get_chartdata-service aan met jouw geconfigureerde YAML-configuratie en stelt het resultaat beschikbaar als entiteitsattributen. De status toont 'ready' wanneer data beschikbaar is, 'error' bij fouten, of 'pending' voor de eerste aanroep. Perfekt voor dashboard-integraties zoals ApexCharts die prijsgegevens uit entiteitsattributen moeten lezen.",

View file

@ -486,6 +486,21 @@
"long_description": "Visar om ditt Tibber-abonnemang för närvarande är aktivt, har avslutats eller väntar på aktivering. En status 'Aktiv' betyder att du aktivt tar emot elektricitet genom Tibber.", "long_description": "Visar om ditt Tibber-abonnemang för närvarande är aktivt, har avslutats eller väntar på aktivering. En status 'Aktiv' betyder att du aktivt tar emot elektricitet genom Tibber.",
"usage_tips": "Använd detta för att övervaka din abonnemangsstatus. Ställ in varningar om statusen ändras från 'Aktiv' för att säkerställa oavbruten service." "usage_tips": "Använd detta för att övervaka din abonnemangsstatus. Ställ in varningar om statusen ändras från 'Aktiv' för att säkerställa oavbruten service."
}, },
"day_pattern_yesterday": {
"description": "Detekterat prismönster för gårdagens elpriser",
"long_description": "Klassificerar igår i ett prismönster: Dal (billigt på mitten), Topp (dyrt på mitten), Dubbeldal (W-form, två billiga perioder), Dubbeltopp (M-form, två dyra toppar), Flat (liten variation), Stigande, Fallande eller Blandad. Konfidensen och CV-attributen visar hur tillförlitligt mönstret detekterades.",
"usage_tips": "Använd gårdagens mönster för att förfina automationer: ett Dalmönster upprepas ofta nästa dag och tyder på att du bör förplanera billiga middagstimmar."
},
"day_pattern_today": {
"description": "Detekterat prismönster för dagens elpriser",
"long_description": "Klassificerar idag i ett prismönster: Dal (billigt på middagen), Topp (dyrt på middagen), Dubbeldal (W-form), Dubbeltopp (M-form), Flat, Stigande, Fallande eller Blandad. Attributen inkluderar konfidenspoäng (01), variationskoefficient, knäpunkttider och dagsegment.",
"usage_tips": "Använd dagens mönster för att flytta förbrukning. Daldag: kör diskmaskinen, tvättmaskinen eller ladda elbilen på middagen. Toppdag: kör apparater tidigt på morgonen eller sent på kvällen. Använd valley_start och valley_end för precisa automationer."
},
"day_pattern_tomorrow": {
"description": "Detekterat prismönster för morgondagens elpriser",
"long_description": "Klassificerar imorgon (när data finns tillgänglig, vanligtvis efter 13:00) i ett prismönster med samma algoritm som idag. Attributen valley_start/valley_end eller peak_start/peak_end ger knäpunkttider för det primära extremvärdet.",
"usage_tips": "Ställ in kvällsautomationer som läser morgondagens mönster och förkonfigurerar värmepump, billaddare eller varmvattenberedare. Kombinera med tomorrow_data_available-binärsensorn."
},
"chart_data_export": { "chart_data_export": {
"description": "Dataexport för dashboard-integrationer", "description": "Dataexport för dashboard-integrationer",
"long_description": "Denna sensor anropar get_chartdata-tjänsten med din konfigurerade YAML-konfiguration och exponerar resultatet som entitetsattribut. Statusen visar 'ready' när data är tillgänglig, 'error' vid fel, eller 'pending' före första anropet. Perfekt för dashboard-integrationer som ApexCharts som behöver läsa prisdata från entitetsattribut.", "long_description": "Denna sensor anropar get_chartdata-tjänsten med din konfigurerade YAML-konfiguration och exponerar resultatet som entitetsattribut. Statusen visar 'ready' när data är tillgänglig, 'error' vid fel, eller 'pending' före första anropet. Perfekt för dashboard-integrationer som ApexCharts som behöver läsa prisdata från entitetsattribut.",

View file

@ -44,6 +44,7 @@ from .daily_stat import add_statistics_attributes
from .future import add_next_avg_attributes, get_future_prices from .future import add_next_avg_attributes, get_future_prices
from .interval import add_current_interval_price_attributes from .interval import add_current_interval_price_attributes
from .lifecycle import build_lifecycle_attributes from .lifecycle import build_lifecycle_attributes
from .metadata import get_day_pattern_attributes
from .timing import _is_timing_or_volatility_sensor from .timing import _is_timing_or_volatility_sensor
from .trend import _add_cached_trend_attributes, _add_timing_or_volatility_attributes from .trend import _add_cached_trend_attributes, _add_timing_or_volatility_attributes
from .volatility import add_volatility_type_attributes, get_prices_for_volatility from .volatility import add_volatility_type_attributes, get_prices_for_volatility
@ -72,7 +73,7 @@ __all__ = [
] ]
def build_sensor_attributes( def build_sensor_attributes( # noqa: PLR0912
key: str, key: str,
coordinator: TibberPricesDataUpdateCoordinator, coordinator: TibberPricesDataUpdateCoordinator,
native_value: Any, native_value: Any,
@ -189,6 +190,12 @@ def build_sensor_attributes(
elif _is_timing_or_volatility_sensor(key): elif _is_timing_or_volatility_sensor(key):
_add_timing_or_volatility_attributes(attributes, key, cached_data, native_value, time=time) _add_timing_or_volatility_attributes(attributes, key, cached_data, native_value, time=time)
elif key in ("day_pattern_yesterday", "day_pattern_today", "day_pattern_tomorrow"):
day = key.removeprefix("day_pattern_")
day_attrs = get_day_pattern_attributes(coordinator, day)
if day_attrs:
attributes.update(day_attrs)
# For current_interval_price_level, add the original level as attribute # For current_interval_price_level, add the original level as attribute
if key == "current_interval_price_level" and cached_data.get("last_price_level") is not None: if key == "current_interval_price_level" and cached_data.get("last_price_level") is not None:
attributes["level_id"] = cached_data["last_price_level"] attributes["level_id"] = cached_data["last_price_level"]

View file

@ -2,7 +2,7 @@
from __future__ import annotations from __future__ import annotations
from typing import TYPE_CHECKING from typing import TYPE_CHECKING, Any
from custom_components.tibber_prices.utils.price import find_price_data_for_interval from custom_components.tibber_prices.utils.price import find_price_data_for_interval
@ -35,3 +35,77 @@ def get_current_interval_data(
now = time.now() now = time.now()
return find_price_data_for_interval(coordinator.data, now, time=time) return find_price_data_for_interval(coordinator.data, now, time=time)
def get_day_pattern_attributes(
coordinator: TibberPricesDataUpdateCoordinator,
day: str,
) -> dict[str, Any] | None:
"""
Build attributes for a day_pattern_* sensor.
Returns the full DayPatternDict fields (except "pattern" which is the sensor
state) plus ISO-formatted datetime fields.
Args:
coordinator: The data update coordinator.
day: One of "yesterday", "today", "tomorrow".
time: TibberPricesTimeService instance.
Returns:
Attribute dict or None if pattern data is unavailable.
"""
if not coordinator.data:
return None
day_patterns = coordinator.data.get("dayPatterns")
if not day_patterns:
return None
day_data: dict[str, Any] | None = day_patterns.get(day)
if not day_data:
return None
def _iso(val: object) -> str | None:
"""Convert datetime to ISO string, pass strings through, return None otherwise."""
if val is None:
return None
if isinstance(val, str):
return val
if hasattr(val, "isoformat"):
return val.isoformat() # type: ignore[return-value]
return None
attrs: dict[str, Any] = {
"confidence": day_data.get("confidence"),
"day_cv_percent": day_data.get("day_cv_percent"),
}
# Optional primary extreme time
extreme_time = _iso(day_data.get("extreme_time"))
if extreme_time is not None:
attrs["extreme_time"] = extreme_time
# VALLEY-specific knee points
valley_start = _iso(day_data.get("valley_start"))
valley_end = _iso(day_data.get("valley_end"))
if valley_start is not None:
attrs["valley_start"] = valley_start
if valley_end is not None:
attrs["valley_end"] = valley_end
# PEAK-specific knee points
peak_start = _iso(day_data.get("peak_start"))
peak_end = _iso(day_data.get("peak_end"))
if peak_start is not None:
attrs["peak_start"] = peak_start
if peak_end is not None:
attrs["peak_end"] = peak_end
# Segments (list of monotone regions)
segments = day_data.get("segments")
if segments:
attrs["segments"] = segments
return attrs or None

View file

@ -113,3 +113,27 @@ class TibberPricesMetadataCalculator(TibberPricesBaseCalculator):
return value.lower() return value.lower()
return value return value
def get_day_pattern_value(self, day: str) -> str | None:
"""
Get the detected price pattern for a calendar day.
Args:
day: One of "yesterday", "today", or "tomorrow".
Returns:
Pattern string (e.g. "valley", "peak", "flat") or None if not available.
"""
if not self.coordinator.data:
return None
day_patterns = self.coordinator.data.get("dayPatterns")
if not day_patterns:
return None
day_data = day_patterns.get(day)
if not day_data:
return None
return day_data.get("pattern")

View file

@ -865,7 +865,70 @@ PEAK_PRICE_TIMING_SENSORS = (
), ),
) )
# 8. DIAGNOSTIC SENSORS (data availability and metadata) # 8. DAY PATTERN SENSORS (price shape classification per calendar day)
# ----------------------------------------------------------------------------
DAY_PATTERN_SENSORS = (
SensorEntityDescription(
key="day_pattern_yesterday",
translation_key="day_pattern_yesterday",
icon="mdi:chart-bell-curve",
device_class=SensorDeviceClass.ENUM,
options=[
"valley",
"peak",
"double_valley",
"double_peak",
"flat",
"rising",
"falling",
"mixed",
],
state_class=None,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
),
SensorEntityDescription(
key="day_pattern_today",
translation_key="day_pattern_today",
icon="mdi:chart-bell-curve",
device_class=SensorDeviceClass.ENUM,
options=[
"valley",
"peak",
"double_valley",
"double_peak",
"flat",
"rising",
"falling",
"mixed",
],
state_class=None,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=True,
),
SensorEntityDescription(
key="day_pattern_tomorrow",
translation_key="day_pattern_tomorrow",
icon="mdi:chart-bell-curve",
device_class=SensorDeviceClass.ENUM,
options=[
"valley",
"peak",
"double_valley",
"double_peak",
"flat",
"rising",
"falling",
"mixed",
],
state_class=None,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
),
)
# 9. DIAGNOSTIC SENSORS (data availability and metadata)
# ---------------------------------------------------------------------------- # ----------------------------------------------------------------------------
DIAGNOSTIC_SENSORS = ( DIAGNOSTIC_SENSORS = (
@ -1055,5 +1118,6 @@ ENTITY_DESCRIPTIONS = (
*VOLATILITY_SENSORS, *VOLATILITY_SENSORS,
*BEST_PRICE_TIMING_SENSORS, *BEST_PRICE_TIMING_SENSORS,
*PEAK_PRICE_TIMING_SENSORS, *PEAK_PRICE_TIMING_SENSORS,
*DAY_PATTERN_SENSORS,
*DIAGNOSTIC_SENSORS, *DIAGNOSTIC_SENSORS,
) )

View file

@ -238,6 +238,10 @@ def get_value_getter_mapping( # noqa: PLR0913 - needs all calculators as parame
), ),
# Subscription sensors (via MetadataCalculator) # Subscription sensors (via MetadataCalculator)
"subscription_status": lambda: metadata_calculator.get_subscription_value("status"), "subscription_status": lambda: metadata_calculator.get_subscription_value("status"),
# Day pattern sensors (via MetadataCalculator)
"day_pattern_yesterday": lambda: metadata_calculator.get_day_pattern_value("yesterday"),
"day_pattern_today": lambda: metadata_calculator.get_day_pattern_value("today"),
"day_pattern_tomorrow": lambda: metadata_calculator.get_day_pattern_value("tomorrow"),
# Volatility sensors (via VolatilityCalculator) # Volatility sensors (via VolatilityCalculator)
"today_volatility": lambda: volatility_calculator.get_volatility_value(volatility_type="today"), "today_volatility": lambda: volatility_calculator.get_volatility_value(volatility_type="today"),
"tomorrow_volatility": lambda: volatility_calculator.get_volatility_value(volatility_type="tomorrow"), "tomorrow_volatility": lambda: volatility_calculator.get_volatility_value(volatility_type="tomorrow"),

View file

@ -937,6 +937,45 @@
"unknown": "Unbekannt" "unknown": "Unbekannt"
} }
}, },
"day_pattern_yesterday": {
"name": "Preismuster Gestern",
"state": {
"valley": "Tal",
"peak": "Gipfel",
"double_valley": "Doppeltal",
"double_peak": "Doppelgipfel",
"flat": "Flach",
"rising": "Steigend",
"falling": "Fallend",
"mixed": "Gemischt"
}
},
"day_pattern_today": {
"name": "Preismuster Heute",
"state": {
"valley": "Tal",
"peak": "Gipfel",
"double_valley": "Doppeltal",
"double_peak": "Doppelgipfel",
"flat": "Flach",
"rising": "Steigend",
"falling": "Fallend",
"mixed": "Gemischt"
}
},
"day_pattern_tomorrow": {
"name": "Preismuster Morgen",
"state": {
"valley": "Tal",
"peak": "Gipfel",
"double_valley": "Doppeltal",
"double_peak": "Doppelgipfel",
"flat": "Flach",
"rising": "Steigend",
"falling": "Fallend",
"mixed": "Gemischt"
}
},
"chart_data_export": { "chart_data_export": {
"name": "Diagramm-Datenexport", "name": "Diagramm-Datenexport",
"state": { "state": {

View file

@ -937,6 +937,45 @@
"unknown": "Unknown" "unknown": "Unknown"
} }
}, },
"day_pattern_yesterday": {
"name": "Yesterday's Price Pattern",
"state": {
"valley": "Valley",
"peak": "Peak",
"double_valley": "Double Valley",
"double_peak": "Double Peak",
"flat": "Flat",
"rising": "Rising",
"falling": "Falling",
"mixed": "Mixed"
}
},
"day_pattern_today": {
"name": "Today's Price Pattern",
"state": {
"valley": "Valley",
"peak": "Peak",
"double_valley": "Double Valley",
"double_peak": "Double Peak",
"flat": "Flat",
"rising": "Rising",
"falling": "Falling",
"mixed": "Mixed"
}
},
"day_pattern_tomorrow": {
"name": "Tomorrow's Price Pattern",
"state": {
"valley": "Valley",
"peak": "Peak",
"double_valley": "Double Valley",
"double_peak": "Double Peak",
"flat": "Flat",
"rising": "Rising",
"falling": "Falling",
"mixed": "Mixed"
}
},
"chart_data_export": { "chart_data_export": {
"name": "Chart Data Export", "name": "Chart Data Export",
"state": { "state": {

View file

@ -937,6 +937,45 @@
"unknown": "Ukjent" "unknown": "Ukjent"
} }
}, },
"day_pattern_yesterday": {
"name": "Prismønster i går",
"state": {
"valley": "Dal",
"peak": "Topp",
"double_valley": "Dobbel dal",
"double_peak": "Dobbel topp",
"flat": "Flat",
"rising": "Stigende",
"falling": "Fallende",
"mixed": "Blandet"
}
},
"day_pattern_today": {
"name": "Prismønster i dag",
"state": {
"valley": "Dal",
"peak": "Topp",
"double_valley": "Dobbel dal",
"double_peak": "Dobbel topp",
"flat": "Flat",
"rising": "Stigende",
"falling": "Fallende",
"mixed": "Blandet"
}
},
"day_pattern_tomorrow": {
"name": "Prismønster i morgen",
"state": {
"valley": "Dal",
"peak": "Topp",
"double_valley": "Dobbel dal",
"double_peak": "Dobbel topp",
"flat": "Flat",
"rising": "Stigende",
"falling": "Fallende",
"mixed": "Blandet"
}
},
"chart_data_export": { "chart_data_export": {
"name": "Diagramdataeksport", "name": "Diagramdataeksport",
"state": { "state": {

View file

@ -937,6 +937,45 @@
"unknown": "Onbekend" "unknown": "Onbekend"
} }
}, },
"day_pattern_yesterday": {
"name": "Prijspatroon Gisteren",
"state": {
"valley": "Dal",
"peak": "Piek",
"double_valley": "Dubbel Dal",
"double_peak": "Dubbele Piek",
"flat": "Vlak",
"rising": "Stijgend",
"falling": "Dalend",
"mixed": "Gemengd"
}
},
"day_pattern_today": {
"name": "Prijspatroon Vandaag",
"state": {
"valley": "Dal",
"peak": "Piek",
"double_valley": "Dubbel Dal",
"double_peak": "Dubbele Piek",
"flat": "Vlak",
"rising": "Stijgend",
"falling": "Dalend",
"mixed": "Gemengd"
}
},
"day_pattern_tomorrow": {
"name": "Prijspatroon Morgen",
"state": {
"valley": "Dal",
"peak": "Piek",
"double_valley": "Dubbel Dal",
"double_peak": "Dubbele Piek",
"flat": "Vlak",
"rising": "Stijgend",
"falling": "Dalend",
"mixed": "Gemengd"
}
},
"chart_data_export": { "chart_data_export": {
"name": "Grafiekdata Export", "name": "Grafiekdata Export",
"state": { "state": {

View file

@ -937,6 +937,45 @@
"unknown": "Okänd" "unknown": "Okänd"
} }
}, },
"day_pattern_yesterday": {
"name": "Prismönster Igår",
"state": {
"valley": "Dal",
"peak": "Topp",
"double_valley": "Dubbeldal",
"double_peak": "Dubbeltopp",
"flat": "Flat",
"rising": "Stigande",
"falling": "Fallande",
"mixed": "Blandad"
}
},
"day_pattern_today": {
"name": "Prismönster Idag",
"state": {
"valley": "Dal",
"peak": "Topp",
"double_valley": "Dubbeldal",
"double_peak": "Dubbeltopp",
"flat": "Flat",
"rising": "Stigande",
"falling": "Fallande",
"mixed": "Blandad"
}
},
"day_pattern_tomorrow": {
"name": "Prismönster Imorgon",
"state": {
"valley": "Dal",
"peak": "Topp",
"double_valley": "Dubbeldal",
"double_peak": "Dubbeltopp",
"flat": "Flat",
"rising": "Stigande",
"falling": "Fallande",
"mixed": "Blandad"
}
},
"chart_data_export": { "chart_data_export": {
"name": "Diagramdataexport", "name": "Diagramdataexport",
"state": { "state": {

View file

@ -208,6 +208,15 @@ explanations of each sensor's purpose, attributes, and automation examples.
| <span id="ref-data_lifecycle_status" class="entity-anchor"></span>`data_lifecycle_status` | Data Lifecycle Status | Datenlebenszyklus-Status | Datalivssyklus-status | Data Levenscyclus Status | Datalivscykelstatus | ✅ | | <span id="ref-data_lifecycle_status" class="entity-anchor"></span>`data_lifecycle_status` | Data Lifecycle Status | Datenlebenszyklus-Status | Datalivssyklus-status | Data Levenscyclus Status | Datalivscykelstatus | ✅ |
| <span id="ref-chart_data_export" class="entity-anchor"></span>`chart_data_export` | Chart Data Export | Diagramm-Datenexport | Diagramdataeksport | Grafiekdata Export | Diagramdataexport | ❌ | | <span id="ref-chart_data_export" class="entity-anchor"></span>`chart_data_export` | Chart Data Export | Diagramm-Datenexport | Diagramdataeksport | Grafiekdata Export | Diagramdataexport | ❌ |
| <span id="ref-chart_metadata" class="entity-anchor"></span>`chart_metadata` | Chart Metadata | Diagramm-Metadaten | Diagrammetadata | Grafiek Metadata | Diagrammetadata | ✅ | | <span id="ref-chart_metadata" class="entity-anchor"></span>`chart_metadata` | Chart Metadata | Diagramm-Metadaten | Diagrammetadata | Grafiek Metadata | Diagrammetadata | ✅ |
### Other
| Entity ID suffix | 🇬🇧 English | 🇩🇪 Deutsch | 🇳🇴 Norsk | 🇳🇱 Nederlands | 🇸🇪 Svenska | Default |
|---|---|---|---|---|---|---|
| <span id="ref-day_pattern_today" class="entity-anchor"></span>`day_pattern_today` | Today's Price Pattern | Preismuster Heute | Prismønster i dag | Prijspatroon Vandaag | Prismönster Idag | ✅ |
| <span id="ref-day_pattern_tomorrow" class="entity-anchor"></span>`day_pattern_tomorrow` | Tomorrow's Price Pattern | Preismuster Morgen | Prismønster i morgen | Prijspatroon Morgen | Prismönster Imorgon | ❌ |
| <span id="ref-day_pattern_yesterday" class="entity-anchor"></span>`day_pattern_yesterday` | Yesterday's Price Pattern | Preismuster Gestern | Prismønster i går | Prijspatroon Gisteren | Prismönster Igår | ❌ |
## Binary Sensors ## Binary Sensors
### Binary Sensors ### Binary Sensors