Compare commits

...

3 commits

Author SHA1 Message Date
Julian Pawlowski
2b63440933 refactor(periods): enhance peak period filtering and validation logic
Some checks failed
Deploy Docusaurus Documentation (Dual Sites) / Build and Deploy Documentation Sites (push) Waiting to run
Lint / Ruff (push) Waiting to run
Validate / Hassfest validation (push) Waiting to run
Validate / HACS validation (push) Waiting to run
Auto-Tag on Version Bump / Check and create version tag (push) Has been cancelled
Improve the filtering of peak periods to eliminate cross-day artifacts and ensure that only genuine high-price windows are retained. This includes adjustments to the criteria for peak classification and the introduction of validation against previous day's prices for overnight intervals.

Impact: Users will experience more accurate peak pricing data, reducing misleading peak classifications on flat days.
2026-04-17 22:24:18 +00:00
Julian Pawlowski
4f2bea6720 docs(concepts): clarify V-shaped and U-shaped price day definitions
Enhanced descriptions for V-shaped and U-shaped price days to include their characteristics and how they are classified as 'valley' by the Day Pattern sensor.

Impact: Users gain a clearer understanding of price patterns and their implications for automation.

docs(glossary): update V-Shaped Day definition for clarity

Revised the definition of V-Shaped Day to include U-Shaped Day as an informal term, emphasizing the classification as 'valley' by the Day Pattern sensor.

Impact: Users receive a more comprehensive explanation of price day classifications.

docs(period-calculation): improve algorithm overview and phase descriptions

Added detailed explanations for each phase of the period calculation algorithm, including visual aids and clarifications on the importance of each phase.

Impact: Users can better understand the underlying logic of price period calculations.

docs(sensors-price-phases): refine sensor descriptions for clarity

Updated descriptions of price phase sensors to clarify the shapes and conditions they represent, including distinctions between V-shaped and U-shaped curves.

Impact: Users benefit from improved clarity on sensor classifications and their applications in automations.
2026-04-17 22:23:55 +00:00
Julian Pawlowski
8ebff9bc9a chore(devcontainer): add MermaidChart extension for diagram support
Include the MermaidChart extension to enhance diagramming capabilities within the development environment.

Impact: Users can now create and visualize diagrams directly in their code editor.
2026-04-17 21:03:57 +00:00
11 changed files with 508 additions and 217 deletions

View file

@ -34,7 +34,8 @@
"ms-python.vscode-pylance",
"ms-vscode-remote.remote-containers",
"redhat.vscode-yaml",
"ryanluker.vscode-coverage-gutters"
"ryanluker.vscode-coverage-gutters",
"MermaidChart.vscode-mermaid-chart"
],
"settings": {
"editor.tabSize": 4,

View file

@ -20,6 +20,7 @@ from .period_building import (
filter_periods_by_end_date,
filter_periods_by_min_length,
filter_superseded_periods,
filter_weak_peak_periods,
split_intervals_by_day,
)
from .period_statistics import extract_period_summaries
@ -270,6 +271,16 @@ def calculate_periods(
reverse_sort=reverse_sort,
)
# Step 10: Filter weak peak periods
# Peak periods whose mean price is barely above daily average are likely
# cross-day artifacts rather than genuine high-price windows
if reverse_sort:
period_summaries = filter_weak_peak_periods(
period_summaries,
avg_price_by_day,
time=time,
)
return {
"periods": period_summaries, # Lightweight summaries only
"metadata": {

View file

@ -12,7 +12,7 @@ if TYPE_CHECKING:
from custom_components.tibber_prices.coordinator.time_service import TibberPricesTimeService
from .level_filtering import apply_level_filter, check_interval_criteria, compute_geometric_flex_bonus
from .types import TibberPricesIntervalCriteria
from .types import CROSS_DAY_OVERNIGHT_VALIDATION_HOUR, TibberPricesIntervalCriteria
_LOGGER = logging.getLogger(__name__)
_LOGGER_DETAILS = logging.getLogger(__name__ + ".details")
@ -171,6 +171,23 @@ def build_periods(
effective_criteria = criteria._replace(flex=criteria.flex + geo_bonus) if geo_bonus > 0 else criteria
in_flex, meets_min_distance = check_interval_criteria(price_for_criteria, effective_criteria)
# Cross-day boundary validation for peak periods:
# Overnight intervals (00:00-05:59) must ALSO qualify against the previous
# day's reference price. Without this, prices like 30ct become "peak" against
# tomorrow's lower max (35ct) but weren't peak against today's higher max (39ct).
if reverse_sort and in_flex and starts_at.hour < CROSS_DAY_OVERNIGHT_VALIDATION_HOUR:
prev_day = date_key - timedelta(days=1)
prev_criteria = criteria_by_day.get(prev_day)
if prev_criteria is not None:
prev_effective = (
prev_criteria._replace(flex=prev_criteria.flex + geo_bonus) if geo_bonus > 0 else prev_criteria
)
in_prev_flex, _ = check_interval_criteria(price_for_criteria, prev_effective)
if not in_prev_flex:
# Fails against previous day → boundary artifact, treat as not in flex
in_flex = False
intervals_filtered_by_flex += 1
# Track why intervals are filtered
if not in_flex:
intervals_filtered_by_flex += 1
@ -391,6 +408,86 @@ def _filter_superseded_today_periods(
return kept
def _filter_best_superseded_periods(
today_late: list[dict],
tomorrow_early: list[dict],
other: list[dict],
improvement_threshold: float,
) -> list[dict]:
"""Filter best-price today-late periods superseded by cheaper tomorrow alternatives."""
if not tomorrow_early:
return other + today_late + tomorrow_early
# Find the cheapest tomorrow early period
best_tomorrow = min(tomorrow_early, key=lambda p: p.get("price_mean", float("inf")))
best_tomorrow_price = best_tomorrow.get("price_mean")
if best_tomorrow_price is None:
return other + today_late + tomorrow_early
kept_today = _filter_superseded_today_periods(
today_late,
best_tomorrow,
best_tomorrow_price,
improvement_threshold,
)
return other + kept_today + tomorrow_early
def _filter_peak_superseded_periods(
today_late: list[dict],
tomorrow_early: list[dict],
other: list[dict],
improvement_threshold: float,
) -> list[dict]:
"""
Filter peak-price tomorrow-early periods that are artifacts of day-boundary reclassification.
If today has a genuine late-night peak and tomorrow's early-morning "peak" is
significantly LOWER in price, the tomorrow period is a cross-day artifact:
the same overnight prices are classified differently because they sit near
a different day's maximum.
"""
if not today_late or not tomorrow_early:
return other + today_late + tomorrow_early
# Find the strongest today late peak (highest mean price)
best_today_peak = max(today_late, key=lambda p: p.get("price_mean", 0))
best_today_price = best_today_peak.get("price_mean")
if best_today_price is None or best_today_price <= 0:
return other + today_late + tomorrow_early
kept_tomorrow: list[dict] = []
for tomorrow_period in tomorrow_early:
tomorrow_price = tomorrow_period.get("price_mean")
if tomorrow_price is None:
kept_tomorrow.append(tomorrow_period)
continue
# How much LOWER is tomorrow's peak vs today's peak? (as percentage)
price_drop_pct = ((best_today_price - tomorrow_price) / best_today_price * 100) if best_today_price > 0 else 0
if price_drop_pct >= improvement_threshold:
_LOGGER.info(
"Peak supersession: Tomorrow %s-%s (%.2f) is %.1f%% below today's peak %s-%s (%.2f) → filtered as artifact",
tomorrow_period["start"].strftime("%H:%M"),
tomorrow_period["end"].strftime("%H:%M"),
tomorrow_price,
price_drop_pct,
best_today_peak["start"].strftime("%H:%M"),
best_today_peak["end"].strftime("%H:%M"),
best_today_price,
)
else:
kept_tomorrow.append(tomorrow_period)
return other + today_late + kept_tomorrow
def filter_superseded_periods(
period_summaries: list[dict],
*,
@ -398,19 +495,18 @@ def filter_superseded_periods(
reverse_sort: bool,
) -> list[dict]:
"""
Filter out late-night today periods that are superseded by better tomorrow periods.
Filter out cross-day periods that are artifacts of day-boundary price reclassification.
For BEST PRICE (reverse_sort=False):
When tomorrow's data becomes available, some late-night periods that were found
through relaxation may no longer make sense. If tomorrow has a significantly
better period in the early morning, the late-night today period is obsolete.
better (cheaper) period in the early morning, the late-night today period is obsolete.
Example:
- Today 23:30-00:00 at 0.70 kr (found via relaxation, was best available)
- Tomorrow 04:00-05:30 at 0.50 kr (much better alternative)
The today period is superseded and should be filtered out
This only applies to best-price periods (reverse_sort=False).
Peak-price periods are not filtered this way.
For PEAK PRICE (reverse_sort=True):
Inverted logic: tomorrow's early-morning periods that are significantly LOWER
than today's late-night peak are cross-day artifacts. Overnight prices often
qualify as "peak" against tomorrow's (lower) daily max, but don't represent
genuine high-price windows when viewed across the day boundary.
"""
from .types import ( # noqa: PLC0415
@ -425,8 +521,7 @@ def filter_superseded_periods(
reverse_sort,
)
# Only filter for best-price periods
if reverse_sort or not period_summaries:
if not period_summaries:
return period_summaries
now = time.now()
@ -449,33 +544,139 @@ def filter_superseded_periods(
len(other),
)
# If no tomorrow early periods, nothing to compare against
if not tomorrow_early:
_LOGGER.debug("No tomorrow early periods - skipping supersession check")
return period_summaries
if reverse_sort:
# PEAK: Filter tomorrow-early periods superseded by today-late peaks
result = _filter_peak_superseded_periods(
today_late,
tomorrow_early,
other,
SUPERSESSION_PRICE_IMPROVEMENT_PCT,
)
else:
# BEST: Filter today-late periods superseded by cheaper tomorrow alternatives
result = _filter_best_superseded_periods(
today_late,
tomorrow_early,
other,
SUPERSESSION_PRICE_IMPROVEMENT_PCT,
)
# Find the best tomorrow early period (lowest mean price)
best_tomorrow = min(tomorrow_early, key=lambda p: p.get("price_mean", float("inf")))
best_tomorrow_price = best_tomorrow.get("price_mean")
if best_tomorrow_price is None:
return period_summaries
# Filter superseded today periods
kept_today = _filter_superseded_today_periods(
today_late,
best_tomorrow,
best_tomorrow_price,
SUPERSESSION_PRICE_IMPROVEMENT_PCT,
)
# Reconstruct and sort by start time
result = other + kept_today + tomorrow_early
result.sort(key=lambda p: p.get("start") or time.now())
return result
def filter_weak_peak_periods(
period_summaries: list[dict],
avg_prices: dict,
*,
time: TibberPricesTimeService,
) -> list[dict]:
"""
Filter peak periods whose mean price is barely above the daily average.
A genuine peak period should have prices meaningfully above the daily average.
Periods that are only marginally above average are typically cross-day artifacts
where overnight prices qualify as "peak" against a low daily maximum.
Safety: At least one period per day is always preserved (the one with the
highest premium above average). This prevents removing all peaks on flat days.
Only applies to peak periods. Best-price filtering is not needed because
cheap periods near the daily average are still useful for scheduling.
"""
from .types import CROSS_DAY_OVERNIGHT_VALIDATION_HOUR, PEAK_MIN_PREMIUM_ABOVE_AVG_PCT # noqa: PLC0415
if not period_summaries:
return period_summaries
# Calculate premium for each period and group by day
period_premiums: list[tuple[dict, float, date]] = []
for period in period_summaries:
period_mean = period.get("price_mean")
period_start = period.get("start")
if period_mean is None or period_start is None:
period_premiums.append((period, float("inf"), date.min))
continue
day_key = period_start.date()
daily_avg = avg_prices.get(day_key) or avg_prices.get(str(day_key))
if daily_avg is None or daily_avg <= 0:
period_premiums.append((period, float("inf"), day_key))
continue
# For overnight/morning periods (before 06:00), use the HIGHER of
# current day and previous day averages. This prevents overnight prices
# from appearing as "peaks" when tomorrow's average is lower due to
# midday valleys (e.g., solar surplus). A genuine peak must be high
# relative to BOTH days' price landscape.
effective_avg = daily_avg
if period_start.hour < CROSS_DAY_OVERNIGHT_VALIDATION_HOUR:
prev_day = day_key - timedelta(days=1)
prev_avg = avg_prices.get(prev_day) or avg_prices.get(str(prev_day))
if prev_avg is not None and prev_avg > daily_avg:
effective_avg = prev_avg
_LOGGER_DETAILS.debug(
"%sWeak peak check: Period %s uses prev-day avg %.4f instead of %.4f (overnight cross-day)",
INDENT_L0,
period_start.strftime("%H:%M"),
prev_avg,
daily_avg,
)
premium_pct = ((period_mean - effective_avg) / effective_avg) * 100
period_premiums.append((period, premium_pct, day_key))
# Find the best (highest premium) period per day
best_per_day: dict[date, float] = {}
for _period, premium, day in period_premiums:
if day not in best_per_day or premium > best_per_day[day]:
best_per_day[day] = premium
# Filter: keep periods that pass threshold OR are the best for their day
kept: list[dict] = []
removed = 0
for period, premium, day in period_premiums:
is_best_for_day = premium >= best_per_day.get(day, float("-inf"))
if premium >= PEAK_MIN_PREMIUM_ABOVE_AVG_PCT:
kept.append(period)
elif is_best_for_day:
# Preserve at least one period per day even if below threshold
kept.append(period)
_LOGGER_DETAILS.debug(
"%sWeak peak preserved (best for day %s): premium=%.1f%% < threshold=%.1f%%",
INDENT_L0,
day,
premium,
PEAK_MIN_PREMIUM_ABOVE_AVG_PCT,
)
else:
period_start = period.get("start")
_LOGGER.info(
"Weak peak filtered: Period %s-%s mean=%.2f is only %.1f%% above daily avg (need ≥%.1f%%)",
period_start.strftime("%H:%M") if period_start else "?",
period["end"].strftime("%H:%M") if period.get("end") else "?",
period.get("price_mean", 0),
premium,
PEAK_MIN_PREMIUM_ABOVE_AVG_PCT,
)
removed += 1
if removed > 0:
_LOGGER.info(
"Weak peak filter: %d/%d periods kept (removed %d below %.0f%% premium threshold)",
len(kept),
len(period_summaries),
removed,
PEAK_MIN_PREMIUM_ABOVE_AVG_PCT,
)
return kept
def _is_period_eligible_for_extension(
period: dict,
today: date,

View file

@ -462,17 +462,16 @@ def _compute_day_effective_min(
Uses IQR% as primary metric (robust to isolated price spikes) with CV as
fallback when IQR% is undefined (near-zero or negative median prices).
This applies ONLY to BEST PRICE periods (reverse_sort=False). For PEAK PRICE
periods, full relaxation should run even on flat days because identifying the
genuinely most expensive window requires the complete filter evaluation.
(Design decision: if the user explicitly disabled relaxation, honour the
configured min_periods exactly regardless.)
This applies to both BEST PRICE and PEAK PRICE periods. On flat days,
forcing 2+ peaks via relaxation creates cross-day boundary artifacts
where overnight prices barely qualify as "peak" only because they are
the second-highest block relative to that day's maximum.
Args:
prices_by_day: Dict of date list of price dicts
min_periods: Configured minimum periods per day
enable_relaxation: Whether relaxation is enabled
reverse_sort: True for peak price (no adaptation), False for best price
reverse_sort: True for peak price, False for best price
Returns:
Tuple of (dict of date effective min_periods for that day, count of flat days detected)
@ -482,8 +481,8 @@ def _compute_day_effective_min(
flat_day_count = 0
for day, day_prices in prices_by_day.items():
if not enable_relaxation or min_periods <= 1 or reverse_sort:
# Relaxation disabled, already 1, or peak price: no adaptation
if not enable_relaxation or min_periods <= 1:
# Relaxation disabled or already 1: no adaptation
day_effective_min[day] = min_periods
continue

View file

@ -43,6 +43,19 @@ CROSS_DAY_MAX_PRICE_DEVIATION = 0.15 # Stop if price deviates >15% from origina
# A today period is "superseded" if tomorrow has a significantly better alternative
SUPERSESSION_PRICE_IMPROVEMENT_PCT = 10.0 # Tomorrow must be at least 10% cheaper to supersede
# Peak Price Quality: Minimum premium above daily average to qualify as genuine peak
# A peak period whose mean price is barely above the daily average is likely a
# cross-day artifact rather than a genuine high-price window.
# Example: daily_avg=28ct, premium=10% → peak must average ≥ 30.8ct
PEAK_MIN_PREMIUM_ABOVE_AVG_PCT = 10.0 # Peak mean must be ≥ 10% above daily average
# Cross-Day Boundary Validation: overnight intervals must pass dual-day check
# For peak periods, intervals between 00:00 and this hour must ALSO qualify
# against the previous day's reference price. This prevents artifacts where
# overnight prices (e.g., 30ct) become "peak" against tomorrow's lower max
# but weren't peak against today's higher max.
CROSS_DAY_OVERNIGHT_VALIDATION_HOUR = 6 # Validate 00:00-05:59 against previous day too
# Log indentation levels for visual hierarchy
INDENT_L0 = "" # Top level (calculate_periods_with_relaxation)
INDENT_L1 = " " # Per-day loop

View file

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

View file

@ -88,11 +88,13 @@ This helps you understand if current prices are exceptional or typical.
## V-Shaped and U-Shaped Price Days
Some days show distinctive price curve shapes:
Some days have a price curve with a clear dip in the middle:
- **V-shaped**: Prices drop sharply, hit a brief minimum, then rise sharply again (common during short midday solar surplus)
- **U-shaped**: Prices drop to a low level and stay there for an extended period before rising (common during nighttime or extended low-demand periods)
Both shapes are reported as **`valley`** by the [Day Pattern sensor](sensors-price-phases.md#day-pattern-sensors) — V and U are informal descriptions of the same structural pattern. The width of the cheap window is reflected in the `valley_start` and `valley_end` attributes: a V-shaped day has these close together, a U-shaped day has them far apart.
**Why this matters:** On these days, the Best Price Period may be short (12 hours, covering only the absolute minimum), but prices can remain favorable for 46 hours. By combining [trend sensors](sensors-trends.md) with [price levels](sensors-ratings-levels.md) in automations, you can ride the full cheap wave instead of only using the detected period.
See [Automation Examples → V-Shaped Days](automation-examples.md#understanding-v-shaped-price-days) for practical patterns.

View file

@ -105,8 +105,8 @@ Quick reference for terms used throughout the documentation.
## V
**V-Shaped Day**
: Day with a V- or U-shaped price curve where prices drop to very cheap levels for an extended period. The Best Price Period covers only the absolute minimum, but favorable conditions may last much longer. See [V-Shaped Days](concepts.md#v-shaped-and-u-shaped-price-days).
**V-Shaped Day** (also: U-Shaped Day)
: Informal term for a day where prices dip to a minimum in the middle and rise again on both sides. Both V-shaped (sharp, brief dip) and U-shaped (broad, extended plateau) days are classified as **`valley`** by the [Day Pattern sensor](sensors-price-phases.md#day-pattern-sensors). The Best Price Period covers only the absolute minimum, but favorable conditions may last much longer. See [V-Shaped Days](concepts.md#v-shaped-and-u-shaped-price-days).
**Volatility**
: Measure of price stability (LOW, MEDIUM, HIGH). High volatility = large price swings = good for timing optimization.

View file

@ -9,7 +9,7 @@ Learn how Best Price and Peak Price periods work, and how to configure them for
## Table of Contents
- [Quick Start](#quick-start)
- [How It Works](#how-it-works)
- [How It Works](#how-it-works) — Algorithm overview, all 7 phases explained
- [Configuration Guide](#configuration-guide)
- [Understanding Relaxation](#understanding-relaxation) → [Full Guide](period-relaxation.md)
- [Common Scenarios](#common-scenarios)
@ -57,19 +57,31 @@ The integration sets different **initial defaults** because the features serve d
### Example Timeline
<details>
<summary>Show example: Timeline</summary>
```mermaid
gantt
title Typical Day — Best Price & Peak Price Periods
dateFormat HH:mm
axisFormat %H:%M
tickInterval 2hour
```
00:00 ████████████████ Best Price Period (cheap prices)
04:00 ░░░░░░░░░░░░░░░░ Normal
08:00 ████████████████ Peak Price Period (expensive prices)
12:00 ░░░░░░░░░░░░░░░░ Normal
16:00 ████████████████ Peak Price Period (expensive prices)
20:00 ████████████████ Best Price Period (cheap prices)
```
section Best Price
Cheap prices (00:0004:00) :active, 00:00, 04:00
</details>
section Normal
Normal prices (04:0008:00) : 04:00, 08:00
section Peak Price
Expensive prices (08:0012:00) :crit, 08:00, 12:00
section Normal
Normal prices (12:0016:00) : 12:00, 16:00
section Peak Price
Expensive prices (16:0020:00) :crit, 16:00, 20:00
section Best Price
Cheap prices (20:0000:00) :active, 20:00, 24:00
```
---
@ -87,37 +99,107 @@ On a V-shaped day (prices drop sharply to a minimum, then rise again), the perio
**For flexible loads** (e.g., heat pump, battery charging): you can easily ride the full cheap wave by combining the period sensor with the price level and trend sensors. See [V-Shaped Price Days in Automation Examples](./automation-examples.md#understanding-v-shaped-price-days).
:::
### Algorithm Overview
The period calculation is a multi-phase pipeline. Each phase builds on the previous one, progressively refining the result:
```mermaid
flowchart TD
A["96 intervals per day"] --> B{"① Flexibility<br/><small>Close to MIN/MAX?</small>"}
B -->|Yes| C{"② Distance<br/><small>Meaningfully different<br/>from average?</small>"}
B -->|No| X1["❌ excluded"]
C -->|Yes| D{"③ Duration<br/><small>≥ 60 min?</small>"}
C -->|No| X2["❌ excluded"]
D -->|Yes| E{"④ Level filter<br/><small>(optional)</small>"}
D -->|No| X3["❌ too short"]
E -->|Pass| F["⑤ Spike smoothing"]
E -->|Fail| X4["❌ filtered"]
F --> G["✅ Period found"]
A["📊 Raw price data<br/><small>yesterday + today + tomorrow<br/>(~288 intervals)</small>"]
A --> B["🔇 Phase 1: Spike Smoothing<br/><small>Neutralize isolated price outliers<br/>to prevent period fragmentation</small>"]
B --> C["📐 Phase 2: Day Pattern Detection<br/><small>Classify each day's shape<br/>(valley, peak, duck curve, flat…)</small>"]
C --> D["🔍 Phase 3: Period Detection<br/><small>Find continuous intervals matching<br/>flex + distance + level criteria</small>"]
D --> E["📏 Phase 4: Duration &amp; Quality<br/><small>Remove too-short periods,<br/>calculate statistics</small>"]
E --> F["🌙 Phase 5: Cross-Day Handling<br/><small>Extend across midnight,<br/>filter day-boundary artifacts</small>"]
F --> G{"Enough periods<br/>per day?"}
G -->|Yes| H["✅ Done"]
G -->|No| I["🔄 Phase 6: Relaxation<br/><small>Gradually loosen filters<br/>(+3% flex per step)</small>"]
I -->|retry| D
I -->|exhausted| J["⚠️ Phase 7: Fallback<br/><small>Reduce minimum duration<br/>(last resort)</small>"]
J --> H
style A fill:#e6f7ff,stroke:#00b9e7,stroke-width:2px
style G fill:#e6fff5,stroke:#00c853,stroke-width:2px
style H fill:#e6fff5,stroke:#00c853,stroke-width:2px
style G fill:#fff8e1,stroke:#ffc107,stroke-width:2px
```
**Why this order?**
| Phase | What it does | Why it's needed |
|-------|-------------|-----------------|
| **1. Spike Smoothing** | Replaces isolated price spikes with trend predictions | A single 15-minute spike would split a 4-hour cheap period into two short fragments |
| **2. Day Patterns** | Classifies each day's price shape (valley, peak, duck curve, flat…) | Enables geometric flex bonuses — periods in a detected valley/peak zone get extra margin |
| **3. Period Detection** | Scans all intervals through flex, distance, and level filters | Core logic: finds contiguous blocks where prices are close to the daily min (or max) |
| **4. Duration & Quality** | Removes periods shorter than the configured minimum, calculates statistics | A 15-minute "period" isn't useful for running an appliance |
| **5. Cross-Day Handling** | Extends late-evening periods across midnight, filters day-boundary artifacts | Without this, a cheap period at 23:00-00:00 can't continue into 00:00-02:00 even if prices stay low |
| **6. Relaxation** | Loosens filters step by step (+3% flex) until enough periods are found | On some days, the configured flex isn't enough to find 2 periods — relaxation adapts automatically |
| **7. Fallback** | Progressively reduces minimum duration (60→45→30 min) | Last resort for days where even full relaxation finds zero periods |
The following sections explain each phase in detail.
### Phase 1: Spike Smoothing (Preprocessing)
Before any period detection begins, isolated price spikes are detected and smoothed. This prevents a single expensive 15-minute interval from splitting what should be one long cheap period into two short fragments.
<details>
<summary>Show example: Automatic Price Spike Smoothing</summary>
```
Original prices: 18, 19, 35, 20, 19 ct ← 35 ct is an isolated outlier
Smoothed: 18, 19, 19, 20, 19 ct ← Spike replaced with trend prediction
Result: Continuous period 00:00-01:15 instead of split periods
```
</details>
**How it works:** The algorithm looks at 3 intervals before and after each price point, calculates an expected trend, and flags prices that deviate significantly. On flat days (low price variation), smoothing is more conservative; on volatile days, it's more aggressive. Daily minimum and maximum prices are never smoothed — they serve as reference points for period detection.
**Important:**
- Original prices are always preserved in all statistics (min/max/avg show real values)
- Smoothing only affects which intervals are combined into periods
- The attribute `period_interval_smoothed_count` shows how many intervals were smoothed
### Phase 2: Day Pattern Detection
The integration classifies each day's price shape to optimize period detection for different market conditions:
| Pattern | Shape | Description |
|---------|-------|-------------|
| `valley` | | Single cheap window (e.g., solar midday dip) |
| `peak` | ∩ | Single expensive window (e.g., evening demand) |
| `double_dip` | W | Two cheap windows (e.g., cheap morning + cheap midday) |
| `duck_curve` | M | Two expensive peaks (e.g., morning + evening demand, named after the energy industry's [duck curve](https://en.wikipedia.org/wiki/Duck_curve)) |
| `flat` | ─ | Little variation throughout the day |
| `rising` | / | Prices climb steadily |
| `falling` | \ | Prices drop steadily |
**Why this matters:** On a detected valley day, the period detection gets a geometric flex bonus for intervals within the valley zone — making it easier to capture the full cheap window even if some intervals are slightly above the normal flex threshold. On flat days, the target number of periods is automatically reduced to 1 (see [Flat Day Detection](#fewer-periods-than-configured)).
Day patterns are also exposed as dedicated sensors — see [Price Phases & Day Pattern](./sensors-price-phases.md) for details.
### Phase 3: Period Detection (Core Logic)
This is the heart of the algorithm. Each smoothed interval is tested against three filters. Only consecutive intervals that pass **all three** form a period:
```mermaid
flowchart TD
A["Each 15-min interval"] --> B{"① Flexibility<br/><small>Close to daily MIN/MAX?</small>"}
B -->|Yes| C{"② Distance<br/><small>Meaningfully different<br/>from daily average?</small>"}
B -->|No| X1["❌ excluded"]
C -->|Yes| D{"③ Level filter<br/><small>(optional, user-configured)</small>"}
C -->|No| X2["❌ excluded"]
D -->|Pass| E["✅ Add to current period"]
D -->|Fail| X3["❌ filtered"]
style A fill:#e6f7ff,stroke:#00b9e7,stroke-width:2px
style E fill:#e6fff5,stroke:#00c853,stroke-width:2px
style X1 fill:#fff0f0,stroke:#ff5252,stroke-width:1px,color:#999
style X2 fill:#fff0f0,stroke:#ff5252,stroke-width:1px,color:#999
style X3 fill:#fff0f0,stroke:#ff5252,stroke-width:1px,color:#999
style X4 fill:#fff0f0,stroke:#ff5252,stroke-width:1px,color:#999
```
Think of it like this:
1. **Find potential windows** - Times close to the daily MIN (Best Price) or MAX (Peak Price)
2. **Filter by quality** - Ensure they're meaningfully different from average
3. **Check duration** - Must be long enough to be useful
4. **Apply preferences** - Optional: only show stable prices, avoid mediocre times
### Step-by-Step Process
#### 1. Define the Search Range (Flexibility)
#### ① Flexibility (Search Range)
**Best Price:** How much MORE than the daily minimum can a price be?
@ -145,9 +227,9 @@ Flexibility: -15% (default)
</details>
**Why flexibility?** Prices rarely stay at exactly MIN/MAX. Flexibility lets you capture realistic time windows.
**Why flexibility?** Prices rarely stay at exactly MIN/MAX. Flexibility lets you capture realistic time windows. At high flex values (>20%), the distance filter is automatically scaled down to prevent conflicting constraints.
#### 2. Ensure Quality (Distance from Average)
#### ② Distance from Average (Quality Gate)
Periods must be meaningfully different from the daily average:
@ -166,9 +248,13 @@ Peak Price: Must be ≥ 31.5 ct/kWh (30 + 5%)
**Why?** This prevents marking mediocre times as "best" just because they're slightly below average.
#### 3. Check Duration
#### ③ Level Filter (Optional)
Periods must be long enough to be practical:
You can optionally require intervals to have a specific Tibber price level (e.g., `CHEAP` or `EXPENSIVE`). With gap tolerance, a few "mediocre" intervals within an otherwise good period are allowed — preventing unnecessary splits.
### Phase 4: Duration & Quality
Consecutive intervals that passed all three filters are grouped into candidate periods. Short candidates are discarded:
<details>
<summary>Show example: Minimum Period Duration</summary>
@ -176,62 +262,99 @@ Periods must be long enough to be practical:
```
Default: 60 minutes minimum
45-minute period → Discarded
45-minute period → Discarded (too short to be useful)
90-minute period → Kept ✓
```
</details>
#### 4. Apply Optional Filters
For each surviving period, the integration calculates statistics: mean, median, min, max, price spread, coefficient of variation, and volatility classification. Periods with very heterogeneous prices (CV > 25%) are flagged as low quality.
You can optionally require:
### Phase 5: Cross-Day Handling
- **Absolute quality** (level filter) - "Only show if prices are CHEAP/EXPENSIVE (not just below/above average)"
Since the integration processes yesterday + today + tomorrow together, periods can naturally span midnight. This phase ensures correct behavior at day boundaries:
#### 5. Automatic Price Spike Smoothing
**Cross-midnight extension:**
Late-evening periods (starting after 20:00) are extended into the next day if prices remain favorable. Three safety limits apply:
- Maximum 4 hours of extension
- Extension can't exceed 2× the original period length
- Extension stops if prices deviate more than 15% from the original period's mean
Isolated price spikes are automatically detected and smoothed to prevent unnecessary period fragmentation:
**Day-boundary artifact filtering:**
Each day has its own min/max/avg — so the same absolute price can qualify as "cheap" on one day but not the next. The integration catches misleading artifacts with several automatic checks:
- **Peak periods** near midnight must qualify against **both** adjacent days' statistics
- **Peak periods** must exceed the daily average by at least 10% (overnight periods use the higher average of both days)
- Early-morning "peaks" that are significantly weaker than yesterday's late-evening peak are recognized as artifacts and filtered out
<details>
<summary>Show example: Automatic Price Spike Smoothing</summary>
These checks run automatically — no configuration needed.
```
Original prices: 18, 19, 35, 20, 19 ct ← 35 ct is an isolated outlier
Smoothed: 18, 19, 19, 20, 19 ct ← Spike replaced with trend prediction
### Phase 6: Relaxation (Adaptive)
Result: Continuous period 00:00-01:15 instead of split periods
If the baseline detection didn't find enough periods per day, the integration gradually loosens filters:
```mermaid
flowchart LR
A["Baseline:<br/>flex 15%"] -->|"not enough"| B["Step 1:<br/>flex 18%"]
B -->|"not enough"| C["Step 2:<br/>flex 21%"]
C -->|"…"| D["Step N:<br/>flex up to 50%"]
D -->|"still not enough"| E["Level filter<br/>removed"]
style A fill:#e6f7ff,stroke:#00b9e7
style E fill:#fff8e1,stroke:#ffc107
```
</details>
- Each step increases flex by 3% and retries period detection
- Each day is evaluated independently — a day that already has enough periods is skipped
- On **flat days** (price variation < 10%), the target is automatically reduced to 1 period
- Hard limit: flex never exceeds 50%
- The `relaxation_active` and `relaxation_level` attributes show if and how relaxation was applied
**Important:**
- Original prices are always preserved (min/max/avg show real values)
- Smoothing only affects which intervals are combined into periods
- The attribute `period_interval_smoothed_count` shows if smoothing was active
**See [Relaxation Guide](period-relaxation.md)** for a deep dive.
### Phase 7: Fallback (Last Resort)
If all relaxation steps are exhausted and some days still have **zero** periods:
- Minimum duration is progressively reduced: 60 → 45 → 30 minutes
- All other filters are maximally relaxed (50% flex, no distance or level filter)
- Periods found this way are marked with `duration_fallback_active: true`
This ensures that every day has at least one period, even under extreme market conditions.
### Visual Example
**Timeline for a typical day:**
<details>
<summary>Show example: Timeline</summary>
```
Hour: 00 01 02 03 04 05 06 07 08 09 10 11 12 13 14 15 16 17 18 19 20 21 22 23
Price: 18 19 20 28 29 30 35 34 33 32 30 28 25 24 26 28 30 32 31 22 21 20 19 18
Daily MIN: 18 ct | Daily MAX: 35 ct | Daily AVG: 26 ct
Best Price (15% flex = ≤20.7 ct):
████████ ████████████████
00:00-03:00 (3h) 19:00-24:00 (5h)
| Hour | Price | Best Price threshold<br/>≤ 20.7 ct (15% flex) | Peak Price threshold<br/>≥ 29.75 ct (15% flex) |
|------|------:|:---:|:---:|
| 00:00 | **18 ct** | ✅ Best Price | |
| 01:00 | **19 ct** | ✅ Best Price | |
| 02:00 | **20 ct** | ✅ Best Price | |
| 03:00 | 28 ct | | |
| 04:00 | 29 ct | | |
| 05:00 | 29 ct | | |
| 06:00 | **35 ct** | | 🔴 Peak Price |
| 07:00 | **34 ct** | | 🔴 Peak Price |
| 08:00 | **33 ct** | | 🔴 Peak Price |
| 09:00 | **32 ct** | | 🔴 Peak Price |
| 10:00 | **30 ct** | | 🔴 Peak Price |
| 11:00 | 28 ct | | |
| 12:00 | 25 ct | | |
| 13:00 | 24 ct | | |
| 14:00 | 26 ct | | |
| 15:00 | 28 ct | | |
| 16:00 | 30 ct | | 🔴 Peak Price |
| 17:00 | 32 ct | | 🔴 Peak Price |
| 18:00 | 31 ct | | 🔴 Peak Price |
| 19:00 | **22 ct** | | |
| 20:00 | **20 ct** | ✅ Best Price | |
| 21:00 | **19 ct** | ✅ Best Price | |
| 22:00 | **19 ct** | ✅ Best Price | |
| 23:00 | **18 ct** | ✅ Best Price | |
Peak Price (-15% flex = ≥29.75 ct):
████████████████████████
06:00-11:00 (5h)
```
</details>
**Result:** Two Best Price periods (00:0003:00 and 19:0000:00) and two Peak Price periods (06:0011:00 and 16:0019:00).
---
@ -446,7 +569,7 @@ best_price_max_level: cheap # Only show objectively CHEAP periods
## Understanding Relaxation
Sometimes strict filters find too few periods. **Relaxation automatically loosens filters** until a minimum number of periods is found — enabled by default.
As described in [Phase 6](#phase-6-relaxation-adaptive), relaxation automatically loosens filters until a minimum number of periods is found — enabled by default.
**Key benefits:**
- Each day gets exactly the flexibility it needs (per-day independence)
@ -512,19 +635,25 @@ automation:
### Fewer Periods Than Configured
**Symptom:** You configured `min_periods_best: 2` but the sensor shows fewer periods on some days, and the attributes contain `flat_days_detected: 1` or `relaxation_incomplete: true`.
**Symptom:** You configured `min_periods_best: 2` or `min_periods_peak: 2` but the sensor shows fewer periods on some days, and the attributes contain `flat_days_detected: 1` or `relaxation_incomplete: true`.
**If `flat_days_detected` is present:**
This is **expected behavior** on days with very uniform electricity prices. When prices vary by less than ~10% across the day (e.g. on sunny spring days with high solar generation), there is no meaningful second "cheap window" all hours are equally cheap. The integration automatically reduces the target to 1 period for that day.
This is **expected behavior** on days with very uniform electricity prices. When prices vary by less than ~10% across the day (e.g. on sunny spring days with high solar generation), there is no meaningful second "cheap window" or second "expensive peak" all hours are equally priced. The integration automatically reduces the target to 1 period for that day. This applies to both Best Price and Peak Price periods.
<details>
<summary>Show YAML: Flat Day Detection</summary>
```yaml
# Best Price example:
min_periods_configured: 2
period_count_today: 1
flat_days_detected: 1 # Uniform prices today → 1 period is the right answer
# Peak Price example:
min_periods_configured: 2
period_count_today: 1
flat_days_detected: 1 # No distinct peaks today → 1 period is the right answer
```
</details>
@ -732,23 +861,11 @@ This is most common on very flat days (see above) or with very strict filter set
### Midnight Price Classification Changes
**Symptom:** A Best Price period at 23:45 suddenly changes to Peak Price at 00:00 (or vice versa), even though the absolute price barely changed.
**Symptom:** A Best Price period at 23:45 changes classification at 00:00 (or vice versa), even though the absolute price barely changed.
**Why This Happens:**
This is **mathematically correct behavior** caused by how electricity prices are set in the day-ahead market:
**Market Timing:**
- The EPEX SPOT Day-Ahead auction closes at **12:00 CET** each day
- **All prices** for the next day (00:00-23:45) are set at this moment
- Late-day intervals (23:45) are priced **~36 hours before delivery**
- Early-day intervals (00:00) are priced **~12 hours before delivery**
**Why Prices Jump at Midnight:**
1. **Forecast Uncertainty:** Weather, demand, and renewable generation forecasts are more uncertain 36 hours ahead than 12 hours ahead
2. **Risk Buffer:** Late-day prices include a risk premium for this uncertainty
3. **Independent Days:** Each day has its own min/max/avg calculated from its 96 intervals
4. **Relative Classification:** Periods are classified based on their **position within the day's price range**, not absolute prices
Each day has its own price statistics (min, max, avg) calculated independently from its 96 intervals. Periods are classified based on their **position within the day's price range**, not absolute prices. This means the same absolute price can be "cheap" on one day and "average" on the next.
**Example:**
@ -764,50 +881,33 @@ Daily average: 20 ct/kWh
# Day 2 (low volatility, narrow range)
Price range: 17-21 ct/kWh (4 ct span)
Daily average: 19 ct/kWh
00:00: 18.6 ct/kWh → 2.1% below average → PEAK PRICE
00:00: 18.6 ct/kWh → 2.1% below average → NORMAL
# Observation: Absolute price barely changed (18.5 → 18.6 ct)
# But relative position changed dramatically:
# - Day 1: Near the bottom of the range
# - Day 2: Near the middle/top of the range
# Absolute price barely changed (18.5 → 18.6 ct)
# But relative position changed because Day 2 has a different price range
```
</details>
**When This Occurs:**
- **Low-volatility days:** When price span is narrow (< 5 ct/kWh)
- **Stable weather:** Similar conditions across multiple days
- **Market transitions:** Switching between high/low demand seasons
**How the Integration Handles This:**
**How to Detect:**
The [cross-day handling](#phase-5-cross-day-handling) automatically prevents misleading period boundaries at midnight:
Check the volatility sensors to understand if a period flip is meaningful:
- **Peak periods** near midnight are validated against **both** adjacent days' statistics
- **Peak periods** must exceed the daily average by at least 10%, with overnight periods checked against the higher average of both days
- **Cross-day extensions** are capped in length and stop when prices deviate significantly
<details>
<summary>Show YAML: Daily Volatility Check</summary>
These checks run automatically and require no configuration. They ensure that midnight period boundaries reflect genuine price differences, not just day-boundary artifacts.
```yaml
# Check daily volatility (available in integration)
sensor.<home_name>_today_s_price_volatility: 8.2% # Low volatility
sensor.<home_name>_tomorrow_s_price_volatility: 7.9% # Also low
**Additional Strategies for Automations:**
# Low volatility (< 15%) means:
# - Small absolute price differences between periods
# - Classification changes may not be economically significant
# - Consider ignoring period classification on such days
```
</details>
**Handling in Automations:**
You can make your automations volatility-aware:
For extra robustness, you can also make your automations aware of the price environment:
<details>
<summary>Show YAML: Volatility-Aware Automation</summary>
```yaml
# Option 1: Only act on high-volatility days
# Option 1: Only act on high-volatility days (meaningful price differences)
automation:
- alias: "Dishwasher - Best Price (High Volatility Only)"
trigger:
@ -822,7 +922,7 @@ automation:
- service: switch.turn_on
entity_id: switch.dishwasher
# Option 2: Check absolute price, not just classification
# Option 2: Use absolute price threshold instead of classification
automation:
- alias: "Heat Water - Cheap Enough"
trigger:
@ -836,49 +936,14 @@ automation:
action:
- service: switch.turn_on
entity_id: switch.water_heater
# Option 3: Use per-period day volatility (available on period sensors)
automation:
- alias: "EV Charging - Volatility-Aware"
trigger:
- platform: state
entity_id: binary_sensor.<home_name>_best_price_period
to: "on"
condition:
# Check if the period's day has meaningful volatility
- condition: template
value_template: >
{{ state_attr('binary_sensor.<home_name>_best_price_period', 'day_volatility_%') | float(0) > 15 }}
action:
- service: switch.turn_on
entity_id: switch.ev_charger
```
</details>
**Available Per-Period Attributes:**
Each period sensor exposes day volatility and price statistics:
<details>
<summary>Show YAML: Per-Period Volatility Attributes</summary>
```yaml
binary_sensor.<home_name>_best_price_period:
day_volatility_%: 8.2 # Volatility % of the period's day
day_price_min: 1800.0 # Minimum price of the day (ct/kWh)
day_price_max: 2200.0 # Maximum price of the day (ct/kWh)
day_price_span: 400.0 # Difference (max - min) in ct
```
</details>
These attributes allow automations to check: "Is the classification meaningful on this particular day?"
**Summary:**
- ✅ **Expected behavior:** Periods are evaluated per-day, midnight is a natural boundary
- ✅ **Market reality:** Late-day prices have more uncertainty than early-day prices
- ✅ **Solution:** Use volatility sensors, absolute price thresholds, or per-period day volatility attributes
- ✅ **Expected behavior:** Each day has independent price statistics — midnight is a natural boundary
- ✅ **Automatic handling:** Cross-day quality checks prevent misleading period artifacts
- ✅ **Extra safety:** Use volatility sensors or absolute price thresholds in automations for additional robustness
---

View file

@ -65,10 +65,10 @@ These sensors classify the **overall shape** of the day's price curve:
| State | Shape | Description |
|-------|-------|-------------|
| `valley` | | Cheap in the middle of the day (typical summer midday solar effect) |
| `peak` | ∩ | Expensive in the middle — cheap mornings and evenings |
| `double_valley` | W | Two cheap windows — classic with cheap morning + cheap midday |
| `double_peak` | M | Two expensive peaks — common on workdays with morning and evening demand |
| `valley` | | Cheap in the middle of the day — covers both **V-shaped** (short, sharp dip) and **U-shaped** (extended cheap plateau) curves. Common during solar midday surplus or low-demand nights. |
| `peak` | ∩ | Expensive in the middle — cheap mornings and evenings. Covers both sharp Λ-peaks and broad plateau shapes. |
| `double_dip` | W | Two cheap windows — classic with cheap morning + cheap midday |
| `duck_curve` | M | Two expensive peaks — common on workdays with morning and evening demand (named after the energy industry's [duck curve](https://en.wikipedia.org/wiki/Duck_curve)) |
| `flat` | ─ | Little variation throughout the day |
| `rising` | / | Prices climb steadily through the day |
| `falling` | \ | Prices drop steadily through the day |
@ -98,7 +98,7 @@ automation:
condition:
- condition: template
value_template: >
{{ states('sensor.<home_name>_day_pattern_tomorrow') in ['valley', 'double_valley'] }}
{{ states('sensor.<home_name>_day_pattern_tomorrow') in ['valley', 'double_dip'] }}
- condition: template
value_template: >
{{ is_state('binary_sensor.<home_name>_tomorrow_data_available', 'on') }}

View file

@ -148,7 +148,9 @@ class TestPeakPriceGenerationWorks:
# Bug validation: periods found (not 0)
assert len(periods) > 0, "Peak periods should generate after bug fix"
assert 2 <= len(periods) <= 5, f"Expected 2-5 periods, got {len(periods)}"
# On flat days (IQR% ≤ 15%), min_periods adapts to 1 + weak peak filter
# may remove marginal peaks, so 1-5 periods is acceptable
assert 1 <= len(periods) <= 5, f"Expected 1-5 periods, got {len(periods)}"
def test_negative_flex_normalization_effect(self) -> None:
"""
@ -188,7 +190,8 @@ class TestPeakPriceGenerationWorks:
periods_pos = result_pos.get("periods", [])
# With normalized positive flex, should find periods
assert len(periods_pos) >= 2, f"Should find periods with positive flex, got {len(periods_pos)}"
# Flat day adaptation may reduce min_periods to 1
assert len(periods_pos) >= 1, f"Should find periods with positive flex, got {len(periods_pos)}"
def test_periods_contain_high_prices(self) -> None:
"""
@ -270,8 +273,8 @@ class TestPeakPriceGenerationWorks:
periods = result.get("periods", [])
# Should find periods via relaxation
assert len(periods) >= 2, "Relaxation should find periods"
# Should find periods via relaxation (flat day may adapt to 1 period)
assert len(periods) >= 1, "Relaxation should find periods"
# Check if relaxation was used
relaxation_meta = result.get("metadata", {}).get("relaxation", {})