diff --git a/custom_components/tibber_prices/coordinator/data_transformation.py b/custom_components/tibber_prices/coordinator/data_transformation.py index ab59125..5e7a554 100644 --- a/custom_components/tibber_prices/coordinator/data_transformation.py +++ b/custom_components/tibber_prices/coordinator/data_transformation.py @@ -7,6 +7,7 @@ import logging from typing import TYPE_CHECKING, Any 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 if TYPE_CHECKING: @@ -274,6 +275,12 @@ class TibberPricesDataTransformer: if "priceInfo" in transformed_data: 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 self._cached_transformed_data = transformed_data self._last_transformation_config = self._get_current_transformation_config() diff --git a/custom_components/tibber_prices/coordinator/period_handlers/__init__.py b/custom_components/tibber_prices/coordinator/period_handlers/__init__.py index 14e2213..4ca5b34 100644 --- a/custom_components/tibber_prices/coordinator/period_handlers/__init__.py +++ b/custom_components/tibber_prices/coordinator/period_handlers/__init__.py @@ -19,6 +19,9 @@ from __future__ import annotations # Re-export main API functions from .core import calculate_periods +# Re-export day pattern detection +from .day_pattern import detect_day_patterns + # Re-export outlier filtering from .outlier_filtering import filter_price_outliers @@ -27,12 +30,23 @@ from .relaxation import calculate_periods_with_relaxation # Re-export constants and types 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_L1, INDENT_L2, INDENT_L3, INDENT_L4, INDENT_L5, + DayPatternDict, + SegmentDict, TibberPricesIntervalCriteria, TibberPricesPeriodConfig, TibberPricesPeriodData, @@ -41,12 +55,23 @@ from .types import ( ) __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_L1", "INDENT_L2", "INDENT_L3", "INDENT_L4", "INDENT_L5", + "DayPatternDict", + "SegmentDict", "TibberPricesIntervalCriteria", "TibberPricesPeriodConfig", "TibberPricesPeriodData", @@ -54,5 +79,6 @@ __all__ = [ "TibberPricesThresholdConfig", "calculate_periods", "calculate_periods_with_relaxation", + "detect_day_patterns", "filter_price_outliers", ] diff --git a/custom_components/tibber_prices/coordinator/period_handlers/day_pattern.py b/custom_components/tibber_prices/coordinator/period_handlers/day_pattern.py new file mode 100644 index 0000000..3f6573d --- /dev/null +++ b/custom_components/tibber_prices/coordinator/period_handlers/day_pattern.py @@ -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": , "today": , "tomorrow": }`` + 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 diff --git a/custom_components/tibber_prices/coordinator/period_handlers/types.py b/custom_components/tibber_prices/coordinator/period_handlers/types.py index 142cd58..362a1e7 100644 --- a/custom_components/tibber_prices/coordinator/period_handlers/types.py +++ b/custom_components/tibber_prices/coordinator/period_handlers/types.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import TYPE_CHECKING, NamedTuple +from typing import TYPE_CHECKING, NamedTuple, TypedDict if TYPE_CHECKING: from datetime import datetime @@ -104,3 +104,60 @@ class TibberPricesIntervalCriteria(NamedTuple): flex: float min_distance_from_avg: float 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) diff --git a/custom_components/tibber_prices/custom_translations/de.json b/custom_components/tibber_prices/custom_translations/de.json index de05d16..e3b64aa 100644 --- a/custom_components/tibber_prices/custom_translations/de.json +++ b/custom_components/tibber_prices/custom_translations/de.json @@ -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.", "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 (0–1), 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": { "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.", diff --git a/custom_components/tibber_prices/custom_translations/en.json b/custom_components/tibber_prices/custom_translations/en.json index b289298..fd4b581 100644 --- a/custom_components/tibber_prices/custom_translations/en.json +++ b/custom_components/tibber_prices/custom_translations/en.json @@ -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.", "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": { "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.", diff --git a/custom_components/tibber_prices/custom_translations/nb.json b/custom_components/tibber_prices/custom_translations/nb.json index 5c30b76..81fc575 100644 --- a/custom_components/tibber_prices/custom_translations/nb.json +++ b/custom_components/tibber_prices/custom_translations/nb.json @@ -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.", "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 (0–1), 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": { "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.", diff --git a/custom_components/tibber_prices/custom_translations/nl.json b/custom_components/tibber_prices/custom_translations/nl.json index 7bcd833..bcfb2a1 100644 --- a/custom_components/tibber_prices/custom_translations/nl.json +++ b/custom_components/tibber_prices/custom_translations/nl.json @@ -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.", "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 (0–1), 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": { "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.", diff --git a/custom_components/tibber_prices/custom_translations/sv.json b/custom_components/tibber_prices/custom_translations/sv.json index cafbc51..97e803a 100644 --- a/custom_components/tibber_prices/custom_translations/sv.json +++ b/custom_components/tibber_prices/custom_translations/sv.json @@ -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.", "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 (0–1), 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": { "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.", diff --git a/custom_components/tibber_prices/sensor/attributes/__init__.py b/custom_components/tibber_prices/sensor/attributes/__init__.py index 4089829..ac14568 100644 --- a/custom_components/tibber_prices/sensor/attributes/__init__.py +++ b/custom_components/tibber_prices/sensor/attributes/__init__.py @@ -44,6 +44,7 @@ from .daily_stat import add_statistics_attributes from .future import add_next_avg_attributes, get_future_prices from .interval import add_current_interval_price_attributes from .lifecycle import build_lifecycle_attributes +from .metadata import get_day_pattern_attributes from .timing import _is_timing_or_volatility_sensor from .trend import _add_cached_trend_attributes, _add_timing_or_volatility_attributes 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, coordinator: TibberPricesDataUpdateCoordinator, native_value: Any, @@ -189,6 +190,12 @@ def build_sensor_attributes( elif _is_timing_or_volatility_sensor(key): _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 if key == "current_interval_price_level" and cached_data.get("last_price_level") is not None: attributes["level_id"] = cached_data["last_price_level"] diff --git a/custom_components/tibber_prices/sensor/attributes/metadata.py b/custom_components/tibber_prices/sensor/attributes/metadata.py index 661ac5e..bbdc13c 100644 --- a/custom_components/tibber_prices/sensor/attributes/metadata.py +++ b/custom_components/tibber_prices/sensor/attributes/metadata.py @@ -2,7 +2,7 @@ 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 @@ -35,3 +35,77 @@ def get_current_interval_data( now = time.now() 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 diff --git a/custom_components/tibber_prices/sensor/calculators/metadata.py b/custom_components/tibber_prices/sensor/calculators/metadata.py index aa95ae0..d450a70 100644 --- a/custom_components/tibber_prices/sensor/calculators/metadata.py +++ b/custom_components/tibber_prices/sensor/calculators/metadata.py @@ -113,3 +113,27 @@ class TibberPricesMetadataCalculator(TibberPricesBaseCalculator): return value.lower() 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") diff --git a/custom_components/tibber_prices/sensor/definitions.py b/custom_components/tibber_prices/sensor/definitions.py index 9099864..a9ad3b1 100644 --- a/custom_components/tibber_prices/sensor/definitions.py +++ b/custom_components/tibber_prices/sensor/definitions.py @@ -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 = ( @@ -1055,5 +1118,6 @@ ENTITY_DESCRIPTIONS = ( *VOLATILITY_SENSORS, *BEST_PRICE_TIMING_SENSORS, *PEAK_PRICE_TIMING_SENSORS, + *DAY_PATTERN_SENSORS, *DIAGNOSTIC_SENSORS, ) diff --git a/custom_components/tibber_prices/sensor/value_getters.py b/custom_components/tibber_prices/sensor/value_getters.py index 77cee17..d833e07 100644 --- a/custom_components/tibber_prices/sensor/value_getters.py +++ b/custom_components/tibber_prices/sensor/value_getters.py @@ -238,6 +238,10 @@ def get_value_getter_mapping( # noqa: PLR0913 - needs all calculators as parame ), # Subscription sensors (via MetadataCalculator) "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) "today_volatility": lambda: volatility_calculator.get_volatility_value(volatility_type="today"), "tomorrow_volatility": lambda: volatility_calculator.get_volatility_value(volatility_type="tomorrow"), diff --git a/custom_components/tibber_prices/translations/de.json b/custom_components/tibber_prices/translations/de.json index bf57819..a7cbb4c 100644 --- a/custom_components/tibber_prices/translations/de.json +++ b/custom_components/tibber_prices/translations/de.json @@ -937,6 +937,45 @@ "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": { "name": "Diagramm-Datenexport", "state": { diff --git a/custom_components/tibber_prices/translations/en.json b/custom_components/tibber_prices/translations/en.json index 1fca74c..1ea607c 100644 --- a/custom_components/tibber_prices/translations/en.json +++ b/custom_components/tibber_prices/translations/en.json @@ -937,6 +937,45 @@ "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": { "name": "Chart Data Export", "state": { diff --git a/custom_components/tibber_prices/translations/nb.json b/custom_components/tibber_prices/translations/nb.json index 8c59632..03bace6 100644 --- a/custom_components/tibber_prices/translations/nb.json +++ b/custom_components/tibber_prices/translations/nb.json @@ -937,6 +937,45 @@ "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": { "name": "Diagramdataeksport", "state": { diff --git a/custom_components/tibber_prices/translations/nl.json b/custom_components/tibber_prices/translations/nl.json index bd8d004..e5dddd2 100644 --- a/custom_components/tibber_prices/translations/nl.json +++ b/custom_components/tibber_prices/translations/nl.json @@ -937,6 +937,45 @@ "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": { "name": "Grafiekdata Export", "state": { diff --git a/custom_components/tibber_prices/translations/sv.json b/custom_components/tibber_prices/translations/sv.json index fa12d0e..49c0a1d 100644 --- a/custom_components/tibber_prices/translations/sv.json +++ b/custom_components/tibber_prices/translations/sv.json @@ -937,6 +937,45 @@ "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": { "name": "Diagramdataexport", "state": { diff --git a/docs/user/docs/sensor-reference.md b/docs/user/docs/sensor-reference.md index e345aeb..85a4e6c 100644 --- a/docs/user/docs/sensor-reference.md +++ b/docs/user/docs/sensor-reference.md @@ -208,6 +208,15 @@ explanations of each sensor's purpose, attributes, and automation examples. | `data_lifecycle_status` | Data Lifecycle Status | Datenlebenszyklus-Status | Datalivssyklus-status | Data Levenscyclus Status | Datalivscykelstatus | ✅ | | `chart_data_export` | Chart Data Export | Diagramm-Datenexport | Diagramdataeksport | Grafiekdata Export | Diagramdataexport | ❌ | | `chart_metadata` | Chart Metadata | Diagramm-Metadaten | Diagrammetadata | Grafiek Metadata | Diagrammetadata | ✅ | + +### Other + + +| Entity ID suffix | 🇬🇧 English | 🇩🇪 Deutsch | 🇳🇴 Norsk | 🇳🇱 Nederlands | 🇸🇪 Svenska | Default | +|---|---|---|---|---|---|---| +| `day_pattern_today` | Today's Price Pattern | Preismuster Heute | Prismønster i dag | Prijspatroon Vandaag | Prismönster Idag | ✅ | +| `day_pattern_tomorrow` | Tomorrow's Price Pattern | Preismuster Morgen | Prismønster i morgen | Prijspatroon Morgen | Prismönster Imorgon | ❌ | +| `day_pattern_yesterday` | Yesterday's Price Pattern | Preismuster Gestern | Prismønster i går | Prijspatroon Gisteren | Prismönster Igår | ❌ | ## Binary Sensors ### Binary Sensors