refactor(day_patterns): rename double valley/peak to double dip/duck curve
Some checks are pending
Auto-Tag on Version Bump / Check and create version tag (push) Waiting to run
Lint / Ruff (push) Waiting to run
Validate / Hassfest validation (push) Waiting to run
Validate / HACS validation (push) Waiting to run

Updated pattern names for clarity and consistency in the codebase. The changes include renaming constants and updating related logic to reflect the new terminology.

Impact: Improved readability and understanding of day pattern classifications for developers.
This commit is contained in:
Julian Pawlowski 2026-04-17 14:37:17 +00:00
parent ba3e127ac7
commit 75da094c81
12 changed files with 143 additions and 94 deletions

View file

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

View file

@ -183,7 +183,7 @@ def calculate_periods(
) )
# Step 3.5: Segment forcing for W/M-shaped days (opt-in, default disabled) # Step 3.5: Segment forcing for W/M-shaped days (opt-in, default disabled)
# For days detected as W-shape (DOUBLE_VALLEY for best) or M-shape (DOUBLE_PEAK for peak), # For days detected as W-shape (DOUBLE_DIP for best) or M-shape (DUCK_CURVE for peak),
# ensures each price valley/peak segment has at least segment_min_periods periods. # ensures each price valley/peak segment has at least segment_min_periods periods.
if config.segment_forcing and day_patterns_by_date: if config.segment_forcing and day_patterns_by_date:
raw_periods = _apply_segment_forcing( raw_periods = _apply_segment_forcing(
@ -315,9 +315,9 @@ def _apply_segment_forcing(
""" """
Force at least segment_min_periods periods per segment for W/M-shaped days. Force at least segment_min_periods periods per segment for W/M-shaped days.
For DOUBLE_VALLEY days (best price): splits at the central price peak and For DOUBLE_DIP days (best price): splits at the central price peak and
ensures each valley side has the required number of periods. ensures each valley side has the required number of periods.
For DOUBLE_PEAK days (peak price): splits at the central price valley and For DUCK_CURVE days (peak price): splits at the central price valley and
ensures each peak side has the required number of periods. ensures each peak side has the required number of periods.
Args: Args:
@ -335,12 +335,12 @@ def _apply_segment_forcing(
import logging # noqa: PLC0415 import logging # noqa: PLC0415
from .period_building import build_periods # noqa: PLC0415 from .period_building import build_periods # noqa: PLC0415
from .types import DAY_PATTERN_DOUBLE_PEAK, DAY_PATTERN_DOUBLE_VALLEY, INDENT_L1, INDENT_L2 # noqa: PLC0415 from .types import DAY_PATTERN_DOUBLE_DIP, DAY_PATTERN_DUCK_CURVE, INDENT_L1, INDENT_L2 # noqa: PLC0415
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
reverse_sort = config.reverse_sort reverse_sort = config.reverse_sort
target_pattern = DAY_PATTERN_DOUBLE_PEAK if reverse_sort else DAY_PATTERN_DOUBLE_VALLEY target_pattern = DAY_PATTERN_DUCK_CURVE if reverse_sort else DAY_PATTERN_DOUBLE_DIP
segment_min_periods = config.segment_min_periods segment_min_periods = config.segment_min_periods
merged_periods = list(periods) merged_periods = list(periods)
@ -362,8 +362,8 @@ def _apply_segment_forcing(
continue continue
# Find the central extremum in the middle 50% of the day # Find the central extremum in the middle 50% of the day
# DOUBLE_VALLEY → central peak = highest price between the two valleys # DOUBLE_DIP → central peak = highest price between the two valleys
# DOUBLE_PEAK → central valley = lowest price between the two peaks # DUCK_CURVE → central valley = lowest price between the two peaks
n = len(day_intervals) n = len(day_intervals)
middle = day_intervals[n // 4 : 3 * n // 4] middle = day_intervals[n // 4 : 3 * n // 4]
if not middle: if not middle:

View file

@ -6,8 +6,8 @@ into a small set of patterns that are meaningful for switching decisions:
VALLEY - Single price minimum (U/V-shape, cheap middle) VALLEY - Single price minimum (U/V-shape, cheap middle)
PEAK - Single price maximum (Lambda-shape, expensive middle) PEAK - Single price maximum (Lambda-shape, expensive middle)
DOUBLE_VALLEY - Two minima separated by a peak (W-shape) DOUBLE_DIP - Two minima separated by a peak (W-shape)
DOUBLE_PEAK - Two peaks separated by a valley (M-shape) DUCK_CURVE - Two peaks with midday valley (M-shape, solar duck curve)
FLAT - No significant variation (CV <= 10 %) FLAT - No significant variation (CV <= 10 %)
RISING - Monotonically / persistently rising RISING - Monotonically / persistently rising
FALLING - Monotonically / persistently falling FALLING - Monotonically / persistently falling
@ -63,8 +63,8 @@ _EDGE_ZONE = 0.25
# Pattern string constants # Pattern string constants
DAY_PATTERN_VALLEY = "valley" DAY_PATTERN_VALLEY = "valley"
DAY_PATTERN_PEAK = "peak" DAY_PATTERN_PEAK = "peak"
DAY_PATTERN_DOUBLE_VALLEY = "double_valley" DAY_PATTERN_DOUBLE_DIP = "double_dip"
DAY_PATTERN_DOUBLE_PEAK = "double_peak" DAY_PATTERN_DUCK_CURVE = "duck_curve"
DAY_PATTERN_FLAT = "flat" DAY_PATTERN_FLAT = "flat"
DAY_PATTERN_RISING = "rising" DAY_PATTERN_RISING = "rising"
DAY_PATTERN_FALLING = "falling" DAY_PATTERN_FALLING = "falling"
@ -202,14 +202,14 @@ def _detect_single_day_pattern(
peak_start = times[lk] if lk is not None and lk < len(times) else None peak_start = times[lk] if lk is not None and lk < len(times) else None
peak_end = times[rk] if rk is not None and rk < len(times) else None peak_end = times[rk] if rk is not None and rk < len(times) else None
elif pattern == DAY_PATTERN_DOUBLE_VALLEY and extrema: elif pattern == DAY_PATTERN_DOUBLE_DIP and extrema:
# Primary extreme = deeper of the two minima # Primary extreme = deeper of the two minima
min_extrema = [e for e in extrema if e["type"] == "min"] min_extrema = [e for e in extrema if e["type"] == "min"]
if min_extrema: if min_extrema:
primary = min(min_extrema, key=lambda e: e["price"]) primary = min(min_extrema, key=lambda e: e["price"])
extreme_time = times[primary["idx"]] if primary["idx"] < len(times) else None extreme_time = times[primary["idx"]] if primary["idx"] < len(times) else None
elif pattern == DAY_PATTERN_DOUBLE_PEAK and extrema: elif pattern == DAY_PATTERN_DUCK_CURVE and extrema:
max_extrema = [e for e in extrema if e["type"] == "max"] max_extrema = [e for e in extrema if e["type"] == "max"]
if max_extrema: if max_extrema:
primary = max(max_extrema, key=lambda e: e["price"]) primary = max(max_extrema, key=lambda e: e["price"])
@ -223,15 +223,6 @@ def _detect_single_day_pattern(
lk, rk = _find_knee_points(smoothed, valley_extreme["idx"]) lk, rk = _find_knee_points(smoothed, valley_extreme["idx"])
valley_start = times[lk] if lk is not None and lk < len(times) else None valley_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 valley_end = times[rk] if rk is not None and rk < len(times) else None
# The valley between the two peaks is the cheap zone for best-price periods.
# Compute knee points around the deepest minimum so that compute_geometric_flex_bonus
# can apply extra flex to intervals inside this zone (same mechanism as VALLEY).
min_extrema_dp = [e for e in extrema if e["type"] == "min"]
if min_extrema_dp:
valley_extreme = min(min_extrema_dp, key=lambda e: e["price"])
lk, rk = _find_knee_points(smoothed, valley_extreme["idx"])
valley_start = times[lk] if lk is not None and lk < len(times) else None
valley_end = times[rk] if rk is not None and rk < len(times) else None
# ── intra-day segments ────────────────────────────────────────────────────── # ── intra-day segments ──────────────────────────────────────────────────────
segments = _detect_segments(extrema, prices_raw, times) segments = _detect_segments(extrema, prices_raw, times)
@ -415,11 +406,20 @@ def _classify_pattern(
confidence = max(0.5, 1.0 - cv_pct / FLAT_CV_THRESHOLD) confidence = max(0.5, 1.0 - cv_pct / FLAT_CV_THRESHOLD)
return DAY_PATTERN_FLAT, confidence return DAY_PATTERN_FLAT, confidence
# ── no significant extrema → monotone (rising or falling) ────────────────── # ── no significant extrema → check for monotone trend ──────────────────────
if not extrema: if not extrema:
# Cannot determine direction without access to underlying prices from here. # Without extrema, check if prices have a clear directional trend using
# The caller (_detect_single_day_pattern) handles the RISING/FALLING case # the day's start/end price difference relative to span.
# before calling _classify_pattern when there are no extrema but prices exist. if start_price > 0 and end_price > 0 and n_times >= MIN_DAY_INTERVALS:
price_change = end_price - start_price
# Require at least 5% absolute change relative to the mean price to
# distinguish a genuine trend from flat-ish noise above FLAT_CV_THRESHOLD.
mean_price = (start_price + end_price) / 2
relative_change = abs(price_change) / mean_price if mean_price > 0 else 0
if relative_change > 0.05:
if price_change > 0:
return DAY_PATTERN_RISING, min(0.65, 0.4 + relative_change)
return DAY_PATTERN_FALLING, min(0.65, 0.4 + relative_change)
return DAY_PATTERN_MIXED, 0.4 return DAY_PATTERN_MIXED, 0.4
n_extrema = len(extrema) n_extrema = len(extrema)
@ -461,18 +461,18 @@ def _classify_pattern(
return DAY_PATTERN_VALLEY, 0.65 return DAY_PATTERN_VALLEY, 0.65
return DAY_PATTERN_RISING, 0.7 return DAY_PATTERN_RISING, 0.7
if types == ["min", "min"]: if types == ["min", "min"]:
return DAY_PATTERN_DOUBLE_VALLEY, 0.65 return DAY_PATTERN_DOUBLE_DIP, 0.65
if types == ["max", "max"]: if types == ["max", "max"]:
return DAY_PATTERN_DOUBLE_PEAK, 0.65 return DAY_PATTERN_DUCK_CURVE, 0.65
# ── three extrema ──────────────────────────────────────────────────────────── # ── three extrema ────────────────────────────────────────────────────────────
if n_extrema == 3: if n_extrema == 3:
# min-max-min → W-shape # min-max-min → W-shape
if types == ["min", "max", "min"]: if types == ["min", "max", "min"]:
return DAY_PATTERN_DOUBLE_VALLEY, 0.75 return DAY_PATTERN_DOUBLE_DIP, 0.75
# max-min-max → M-shape # max-min-max → duck curve (solar midday valley between morning/evening peaks)
if types == ["max", "min", "max"]: if types == ["max", "min", "max"]:
return DAY_PATTERN_DOUBLE_PEAK, 0.75 return DAY_PATTERN_DUCK_CURVE, 0.75
# min-max or max-min with trailing → RISING/FALLING with extra bump # min-max or max-min with trailing → RISING/FALLING with extra bump
if types[0] == "min" and types[-1] == "max": if types[0] == "min" and types[-1] == "max":
return DAY_PATTERN_RISING, 0.55 return DAY_PATTERN_RISING, 0.55

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -264,7 +264,7 @@
"best_price_extend_to_very_cheap": "When enabled, detected best price periods expand outward to absorb adjacent intervals with a 'Very cheap' price level. This widens low-price windows to better capture extremely cheap intervals at the edges of detected periods.", "best_price_extend_to_very_cheap": "When enabled, detected best price periods expand outward to absorb adjacent intervals with a 'Very cheap' price level. This widens low-price windows to better capture extremely cheap intervals at the edges of detected periods.",
"best_price_max_extension_intervals": "Maximum number of additional intervals to absorb per side (left and right edge). Each interval is 15 minutes. Example: 4 intervals = up to 1 hour extension per edge. Default: 4", "best_price_max_extension_intervals": "Maximum number of additional intervals to absorb per side (left and right edge). Each interval is 15 minutes. Example: 4 intervals = up to 1 hour extension per edge. Default: 4",
"best_price_geometric_flex": "Extra flex percentage applied to intervals that fall inside a detected price valley (V-shape). When a valley pattern is detected for the day, intervals within the valley zone get this additional tolerance, making the period detector more likely to include them. 0 = disabled. Default: 0", "best_price_geometric_flex": "Extra flex percentage applied to intervals that fall inside a detected price valley (V-shape). When a valley pattern is detected for the day, intervals within the valley zone get this additional tolerance, making the period detector more likely to include them. 0 = disabled. Default: 0",
"best_price_segment_forcing": "When enabled, days with a W-shaped price curve (two valleys separated by a central peak) split at the central peak. Period detection runs independently for each valley side, ensuring each valley has the required number of periods. This prevents both periods from clustering in the same valley. Requires the day pattern sensor to detect a 'double_valley' pattern.", "best_price_segment_forcing": "When enabled, days with a W-shaped price curve (two valleys separated by a central peak) split at the central peak. Period detection runs independently for each valley side, ensuring each valley has the required number of periods. This prevents both periods from clustering in the same valley. Requires the day pattern sensor to detect a 'double_dip' pattern.",
"best_price_segment_min_periods": "Minimum number of best price periods required per valley side when W-shape segment forcing is enabled. Each side must independently produce at least this many periods. Default: 1" "best_price_segment_min_periods": "Minimum number of best price periods required per valley side when W-shape segment forcing is enabled. Each side must independently produce at least this many periods. Default: 1"
} }
} }
@ -329,7 +329,7 @@
"peak_price_extend_to_very_expensive": "When enabled, detected peak price periods expand outward to absorb adjacent intervals with a 'Very expensive' price level. This widens high-price windows to better capture extremely expensive intervals at the edges of detected periods.", "peak_price_extend_to_very_expensive": "When enabled, detected peak price periods expand outward to absorb adjacent intervals with a 'Very expensive' price level. This widens high-price windows to better capture extremely expensive intervals at the edges of detected periods.",
"peak_price_max_extension_intervals": "Maximum number of additional intervals to absorb per side (left and right edge). Each interval is 15 minutes. Example: 4 intervals = up to 1 hour extension per edge. Default: 4", "peak_price_max_extension_intervals": "Maximum number of additional intervals to absorb per side (left and right edge). Each interval is 15 minutes. Example: 4 intervals = up to 1 hour extension per edge. Default: 4",
"peak_price_geometric_flex": "Extra flex percentage applied to intervals that fall inside a detected price peak (Λ-shape). When a peak pattern is detected for the day, intervals within the peak zone get this additional tolerance, making the period detector more likely to include them. 0 = disabled. Default: 0", "peak_price_geometric_flex": "Extra flex percentage applied to intervals that fall inside a detected price peak (Λ-shape). When a peak pattern is detected for the day, intervals within the peak zone get this additional tolerance, making the period detector more likely to include them. 0 = disabled. Default: 0",
"peak_price_segment_forcing": "When enabled, days with an M-shaped price curve (two peaks separated by a central valley) split at the central valley. Period detection runs independently for each peak side, ensuring each peak has the required number of periods. This prevents both periods from clustering in the same peak. Requires the day pattern sensor to detect a 'double_peak' pattern.", "peak_price_segment_forcing": "When enabled, days with an M-shaped price curve (two peaks separated by a central valley) split at the central valley. Period detection runs independently for each peak side, ensuring each peak has the required number of periods. This prevents both periods from clustering in the same peak. Requires the day pattern sensor to detect a 'duck_curve' pattern.",
"peak_price_segment_min_periods": "Minimum number of peak price periods required per peak side when M-shape segment forcing is enabled. Each side must independently produce at least this many periods. Default: 1" "peak_price_segment_min_periods": "Minimum number of peak price periods required per peak side when M-shape segment forcing is enabled. Each side must independently produce at least this many periods. Default: 1"
} }
} }
@ -978,8 +978,8 @@
"state": { "state": {
"valley": "Valley", "valley": "Valley",
"peak": "Peak", "peak": "Peak",
"double_valley": "Double Valley", "double_dip": "Double Dip",
"double_peak": "Double Peak", "duck_curve": "Duck Curve",
"flat": "Flat", "flat": "Flat",
"rising": "Rising", "rising": "Rising",
"falling": "Falling", "falling": "Falling",
@ -991,8 +991,8 @@
"state": { "state": {
"valley": "Valley", "valley": "Valley",
"peak": "Peak", "peak": "Peak",
"double_valley": "Double Valley", "double_dip": "Double Dip",
"double_peak": "Double Peak", "duck_curve": "Duck Curve",
"flat": "Flat", "flat": "Flat",
"rising": "Rising", "rising": "Rising",
"falling": "Falling", "falling": "Falling",
@ -1004,8 +1004,8 @@
"state": { "state": {
"valley": "Valley", "valley": "Valley",
"peak": "Peak", "peak": "Peak",
"double_valley": "Double Valley", "double_dip": "Double Dip",
"double_peak": "Double Peak", "duck_curve": "Duck Curve",
"flat": "Flat", "flat": "Flat",
"rising": "Rising", "rising": "Rising",
"falling": "Falling", "falling": "Falling",

View file

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

View file

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

View file

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