mirror of
https://github.com/jpawlowski/hass.tibber_prices.git
synced 2026-03-29 21:03:40 +00:00
feat(periods): add midnight-crossing periods and day volatility attributes
Periods can now naturally cross midnight boundaries, and new diagnostic attributes help users understand price classification changes at midnight. **New Features:** 1. Midnight-Crossing Period Support (relaxation.py): - group_periods_by_day() assigns periods to ALL spanned days - Periods crossing midnight appear in both yesterday and today - Enables period formation across calendar day boundaries - Ensures min_periods checking works correctly at midnight 2. Extended Price Data Window (relaxation.py): - Period calculation now uses full 3-day data (yesterday+today+tomorrow) - Enables natural period formation without artificial midnight cutoff - Removed date filter that excluded yesterday's prices 3. Day Volatility Diagnostic Attributes (period_statistics.py, core.py): - day_volatility_%: Daily price spread as percentage (span/avg × 100) - day_price_min/max/span: Daily price range in minor currency (ct/øre) - Helps detect when midnight classification changes are economically significant - Uses period start day's reference prices for consistency **Documentation:** 4. Design Principles (period-calculation-theory.md): - Clarified per-day evaluation principle (always was the design) - Added comprehensive section on midnight boundary handling - Documented volatility threshold separation (sensor vs period filters) - Explained market context for midnight price jumps (EPEX SPOT timing) 5. User Guides (period-calculation.md, automation-examples.md): - Added \"Midnight Price Classification Changes\" troubleshooting section - Provided automation examples using volatility attributes - Explained why Best→Peak classification can change at midnight - Documented level filter volatility threshold behavior **Architecture:** - Per-day evaluation: Each interval evaluated against its OWN day's min/max/avg (not period start day) ensures mathematical correctness across midnight - Period boundaries: Periods can naturally cross midnight but may split when consecutive days differ significantly (intentional, mathematically correct) - Volatility thresholds: Sensor thresholds (user-configurable) remain separate from period filter thresholds (fixed internal) to prevent unexpected behavior Impact: Periods crossing midnight are now consistently visible before and after midnight turnover. Users can understand and handle edge cases where price classification changes at midnight on low-volatility days.
This commit is contained in:
parent
dd12f97207
commit
47b0a298d4
7 changed files with 582 additions and 56 deletions
|
|
@ -145,6 +145,7 @@ def calculate_periods(
|
|||
price_context = {
|
||||
"ref_prices": ref_prices,
|
||||
"avg_prices": avg_price_by_day,
|
||||
"intervals_by_day": intervals_by_day, # Needed for day volatility calculation
|
||||
"flex": flex,
|
||||
"min_distance_from_avg": min_distance_from_avg,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -65,9 +65,10 @@ def build_periods( # noqa: PLR0913, PLR0915, PLR0912 - Complex period building
|
|||
"""
|
||||
Build periods, allowing periods to cross midnight (day boundary).
|
||||
|
||||
Periods can span multiple days. Each period uses the reference price (min/max) from
|
||||
the day when the period started, ensuring consistent filtering criteria throughout
|
||||
the period even when crossing midnight.
|
||||
Periods can span multiple days. Each interval is evaluated against the reference
|
||||
price (min/max) and average price of its own day. This ensures fair filtering
|
||||
criteria even when periods cross midnight, where prices can jump significantly
|
||||
due to different forecasting uncertainty (prices at day end vs. day start).
|
||||
|
||||
Args:
|
||||
all_prices: All price data points
|
||||
|
|
@ -105,7 +106,6 @@ def build_periods( # noqa: PLR0913, PLR0915, PLR0912 - Complex period building
|
|||
|
||||
periods: list[list[dict]] = []
|
||||
current_period: list[dict] = []
|
||||
period_start_date: date | None = None # Track start day of current period
|
||||
consecutive_gaps = 0 # Track consecutive intervals that deviate by 1 level step
|
||||
intervals_checked = 0
|
||||
intervals_filtered_by_level = 0
|
||||
|
|
@ -125,11 +125,14 @@ def build_periods( # noqa: PLR0913, PLR0915, PLR0912 - Complex period building
|
|||
|
||||
intervals_checked += 1
|
||||
|
||||
# Use reference price from period start day (for consistency across midnight)
|
||||
# If no period active, use current interval's day
|
||||
ref_date = period_start_date if period_start_date is not None else date_key
|
||||
# CRITICAL: Always use reference price from the interval's own day
|
||||
# Each interval must meet the criteria of its own day, not the period start day.
|
||||
# This ensures fair filtering even when periods cross midnight, where prices
|
||||
# can jump significantly (last intervals of a day have more risk buffer than
|
||||
# first intervals of next day, as they're set with different uncertainty levels).
|
||||
ref_date = date_key
|
||||
|
||||
# Check flex and minimum distance criteria (using smoothed price and period start date reference)
|
||||
# Check flex and minimum distance criteria (using smoothed price and interval's own day reference)
|
||||
criteria = TibberPricesIntervalCriteria(
|
||||
ref_price=ref_prices[ref_date],
|
||||
avg_price=avg_prices[ref_date],
|
||||
|
|
@ -164,10 +167,6 @@ def build_periods( # noqa: PLR0913, PLR0915, PLR0912 - Complex period building
|
|||
|
||||
# Add to period if all criteria are met
|
||||
if in_flex and meets_min_distance and meets_level:
|
||||
# Start new period if none active
|
||||
if not current_period:
|
||||
period_start_date = date_key # Lock reference to start day
|
||||
|
||||
current_period.append(
|
||||
{
|
||||
"interval_hour": starts_at.hour,
|
||||
|
|
@ -184,7 +183,6 @@ def build_periods( # noqa: PLR0913, PLR0915, PLR0912 - Complex period building
|
|||
# Criteria no longer met, end current period
|
||||
periods.append(current_period)
|
||||
current_period = []
|
||||
period_start_date = None # Reset period start date
|
||||
consecutive_gaps = 0 # Reset gap counter
|
||||
|
||||
# Add final period if exists
|
||||
|
|
|
|||
|
|
@ -119,6 +119,7 @@ def build_period_summary_dict(
|
|||
stats: TibberPricesPeriodStatistics,
|
||||
*,
|
||||
reverse_sort: bool,
|
||||
price_context: dict[str, Any] | None = None,
|
||||
) -> dict:
|
||||
"""
|
||||
Build the complete period summary dictionary.
|
||||
|
|
@ -127,6 +128,7 @@ def build_period_summary_dict(
|
|||
period_data: Period timing and position data
|
||||
stats: Calculated period statistics
|
||||
reverse_sort: True for peak price, False for best price (keyword-only)
|
||||
price_context: Optional dict with ref_prices, avg_prices, intervals_by_day for day statistics
|
||||
|
||||
Returns:
|
||||
Complete period summary dictionary following attribute ordering
|
||||
|
|
@ -169,6 +171,30 @@ def build_period_summary_dict(
|
|||
if stats.period_price_diff_pct is not None:
|
||||
summary["period_price_diff_from_daily_min_%"] = stats.period_price_diff_pct
|
||||
|
||||
# Add day volatility and price statistics (for understanding midnight classification changes)
|
||||
if price_context:
|
||||
period_start_date = period_data.start_time.date()
|
||||
intervals_by_day = price_context.get("intervals_by_day", {})
|
||||
avg_prices = price_context.get("avg_prices", {})
|
||||
|
||||
day_intervals = intervals_by_day.get(period_start_date, [])
|
||||
if day_intervals:
|
||||
# Calculate day price statistics (in EUR major units from API)
|
||||
day_prices = [float(p["total"]) for p in day_intervals]
|
||||
day_min = min(day_prices)
|
||||
day_max = max(day_prices)
|
||||
day_span = day_max - day_min
|
||||
day_avg = avg_prices.get(period_start_date, sum(day_prices) / len(day_prices))
|
||||
|
||||
# Calculate volatility percentage (span / avg * 100)
|
||||
day_volatility_pct = round((day_span / day_avg * 100), 1) if day_avg > 0 else 0.0
|
||||
|
||||
# Convert to minor units (ct/øre) for consistency with other price attributes
|
||||
summary["day_volatility_%"] = day_volatility_pct
|
||||
summary["day_price_min"] = round(day_min * 100, 2)
|
||||
summary["day_price_max"] = round(day_max * 100, 2)
|
||||
summary["day_price_span"] = round(day_span * 100, 2)
|
||||
|
||||
return summary
|
||||
|
||||
|
||||
|
|
@ -306,7 +332,9 @@ def extract_period_summaries(
|
|||
)
|
||||
|
||||
# Build complete period summary
|
||||
summary = build_period_summary_dict(period_data, stats, reverse_sort=thresholds.reverse_sort)
|
||||
summary = build_period_summary_dict(
|
||||
period_data, stats, reverse_sort=thresholds.reverse_sort, price_context=price_context
|
||||
)
|
||||
|
||||
# Add smoothing information if any intervals benefited from smoothing
|
||||
if smoothed_impactful_count > 0:
|
||||
|
|
|
|||
|
|
@ -34,28 +34,43 @@ FLEX_HIGH_THRESHOLD_RELAXATION = 0.30 # 30% - WARNING: base flex too high for r
|
|||
|
||||
def group_periods_by_day(periods: list[dict]) -> dict[date, list[dict]]:
|
||||
"""
|
||||
Group periods by the day they end in.
|
||||
Group periods by ALL days they span (including midnight crossings).
|
||||
|
||||
This ensures periods crossing midnight are counted towards the day they end,
|
||||
not the day they start. Example: Period 23:00 yesterday - 02:00 today counts
|
||||
as "today" since it ends today.
|
||||
Periods crossing midnight are assigned to ALL affected days.
|
||||
Example: Period 23:00 yesterday - 02:00 today appears in BOTH days.
|
||||
|
||||
This ensures that:
|
||||
1. For min_periods checking: A midnight-crossing period counts towards both days
|
||||
2. For binary sensors: Each day shows all relevant periods (including those starting/ending in other days)
|
||||
|
||||
Args:
|
||||
periods: List of period summary dicts with "start" and "end" datetime
|
||||
|
||||
Returns:
|
||||
Dict mapping date to list of periods ending on that date
|
||||
Dict mapping date to list of periods spanning that date
|
||||
|
||||
"""
|
||||
periods_by_day: dict[date, list[dict]] = {}
|
||||
|
||||
for period in periods:
|
||||
# Use end time for grouping so periods crossing midnight are counted
|
||||
# towards the day they end (more relevant for min_periods check)
|
||||
start_time = period.get("start")
|
||||
end_time = period.get("end")
|
||||
if end_time:
|
||||
day = end_time.date()
|
||||
periods_by_day.setdefault(day, []).append(period)
|
||||
|
||||
if not start_time or not end_time:
|
||||
continue
|
||||
|
||||
# Assign period to ALL days it spans
|
||||
start_date = start_time.date()
|
||||
end_date = end_time.date()
|
||||
|
||||
# Handle single-day and multi-day periods
|
||||
current_date = start_date
|
||||
while current_date <= end_date:
|
||||
periods_by_day.setdefault(current_date, []).append(period)
|
||||
# Move to next day
|
||||
from datetime import timedelta # noqa: PLC0415
|
||||
|
||||
current_date = current_date + timedelta(days=1)
|
||||
|
||||
return periods_by_day
|
||||
|
||||
|
|
@ -224,18 +239,10 @@ def calculate_periods_with_relaxation( # noqa: PLR0913, PLR0915 - Per-day relax
|
|||
INDENT_L0,
|
||||
)
|
||||
|
||||
# Validate we have price data for today/future
|
||||
today = time.now().date()
|
||||
future_prices = [
|
||||
p
|
||||
for p in all_prices
|
||||
if (interval_time := time.get_interval_time(p)) is not None and interval_time.date() >= today
|
||||
]
|
||||
|
||||
if not future_prices:
|
||||
# No price data for today/future
|
||||
# Validate we have price data
|
||||
if not all_prices:
|
||||
_LOGGER.warning(
|
||||
"No price data available for today/future - cannot calculate periods",
|
||||
"No price data available - cannot calculate periods",
|
||||
)
|
||||
return {"periods": [], "metadata": {}, "reference_data": {}}, {
|
||||
"relaxation_active": False,
|
||||
|
|
@ -244,7 +251,7 @@ def calculate_periods_with_relaxation( # noqa: PLR0913, PLR0915 - Per-day relax
|
|||
"periods_found": 0,
|
||||
}
|
||||
|
||||
# Count available days for logging
|
||||
# Count available days for logging (today and future only)
|
||||
prices_by_day = group_prices_by_day(all_prices, time=time)
|
||||
total_days = len(prices_by_day)
|
||||
|
||||
|
|
@ -253,13 +260,14 @@ def calculate_periods_with_relaxation( # noqa: PLR0913, PLR0915 - Per-day relax
|
|||
total_days,
|
||||
)
|
||||
_LOGGER.debug(
|
||||
"%sProcessing ALL %d price intervals together (allows midnight crossing)",
|
||||
"%sProcessing ALL %d price intervals together (yesterday+today+tomorrow, allows midnight crossing)",
|
||||
INDENT_L1,
|
||||
len(future_prices),
|
||||
len(all_prices),
|
||||
)
|
||||
|
||||
# === BASELINE CALCULATION (process ALL prices together) ===
|
||||
baseline_result = calculate_periods(future_prices, config=config, time=time)
|
||||
# === BASELINE CALCULATION (process ALL prices together, including yesterday) ===
|
||||
# Periods that ended yesterday will be filtered out later by filter_periods_by_end_date()
|
||||
baseline_result = calculate_periods(all_prices, config=config, time=time)
|
||||
all_periods = baseline_result["periods"]
|
||||
|
||||
# Count periods per day for min_periods check
|
||||
|
|
@ -295,9 +303,9 @@ def calculate_periods_with_relaxation( # noqa: PLR0913, PLR0915 - Per-day relax
|
|||
)
|
||||
relaxation_was_needed = True
|
||||
|
||||
# Run relaxation on ALL prices together
|
||||
# Run relaxation on ALL prices together (including yesterday)
|
||||
relaxed_result, relax_metadata = relax_all_prices(
|
||||
all_prices=future_prices,
|
||||
all_prices=all_prices,
|
||||
config=config,
|
||||
min_periods=min_periods,
|
||||
max_relaxation_attempts=max_relaxation_attempts,
|
||||
|
|
@ -363,11 +371,12 @@ def relax_all_prices( # noqa: PLR0913 - Comprehensive filter relaxation require
|
|||
Relax filters for all prices until min_periods per day is reached.
|
||||
|
||||
Strategy: Try increasing flex by 3% increments, then relax level filter.
|
||||
Processes all prices together, allowing periods to cross midnight boundaries.
|
||||
Returns when ALL days have min_periods (or max attempts exhausted).
|
||||
Processes all prices together (yesterday+today+tomorrow), allowing periods
|
||||
to cross midnight boundaries. Returns when ALL days have min_periods
|
||||
(or max attempts exhausted).
|
||||
|
||||
Args:
|
||||
all_prices: All price intervals (today + future)
|
||||
all_prices: All price intervals (yesterday+today+tomorrow)
|
||||
config: Base period configuration
|
||||
min_periods: Target number of periods PER DAY
|
||||
max_relaxation_attempts: Maximum flex levels to try
|
||||
|
|
|
|||
|
|
@ -60,6 +60,42 @@ meets_distance = price >= (daily_avg × (1 + min_distance/100))
|
|||
|
||||
**Logic:** See `level_filtering.py` for gap tolerance details.
|
||||
|
||||
**Volatility Thresholds - Important Separation:**
|
||||
|
||||
The integration maintains **two independent sets** of volatility thresholds:
|
||||
|
||||
1. **Sensor Thresholds** (user-configurable via `CONF_VOLATILITY_*_THRESHOLD`)
|
||||
- Purpose: Display classification in `sensor.tibber_home_volatility_*`
|
||||
- Default: LOW < 10%, MEDIUM < 20%, HIGH ≥ 20%
|
||||
- User can adjust in config flow options
|
||||
- Affects: Sensor state/attributes only
|
||||
|
||||
2. **Period Filter Thresholds** (internal, fixed)
|
||||
- Purpose: Level filter criteria when using `level="volatility_low"` etc.
|
||||
- Source: `PRICE_LEVEL_THRESHOLDS` in `const.py`
|
||||
- Values: Same as sensor defaults (LOW < 10%, MEDIUM < 20%, HIGH ≥ 20%)
|
||||
- User **cannot** adjust these
|
||||
- Affects: Period candidate selection
|
||||
|
||||
**Rationale for Separation:**
|
||||
|
||||
- **Sensor thresholds** = Display preference ("I want to see LOW at 15% instead of 10%")
|
||||
- **Period thresholds** = Algorithm configuration (tested defaults, complex interactions)
|
||||
- Changing sensor display should not affect automation behavior
|
||||
- Prevents unexpected side effects when user adjusts sensor classification
|
||||
- Period calculation has many interacting filters (Flex, Distance, Level) - exposing all internals would be error-prone
|
||||
|
||||
**Implementation:**
|
||||
```python
|
||||
# Sensor classification uses user config
|
||||
user_low_threshold = config_entry.options.get(CONF_VOLATILITY_LOW_THRESHOLD, 10)
|
||||
|
||||
# Period filter uses fixed constants
|
||||
period_low_threshold = PRICE_LEVEL_THRESHOLDS["volatility_low"] # Always 10%
|
||||
```
|
||||
|
||||
**Status:** Intentional design decision (Nov 2025). No plans to expose period thresholds to users.
|
||||
|
||||
---
|
||||
|
||||
## The Flex × Min_Distance Conflict
|
||||
|
|
@ -952,24 +988,116 @@ scale_factor = 0.25 + 0.75 / (1 + exp(10 × (flex - 0.35)))
|
|||
|
||||
**Current Behavior:** Periods can cross midnight naturally
|
||||
|
||||
**Issue:** Period starting 23:45 continues into next day
|
||||
- Uses Day 1's daily_min as reference
|
||||
- May be confusing when Day 2's prices very different
|
||||
**Design Principle:** Each interval is evaluated using its **own day's** reference prices (daily min/max/avg).
|
||||
|
||||
**Alternative Approaches Considered:**
|
||||
1. **Split at midnight** - Always keep periods within calendar day
|
||||
**Implementation:**
|
||||
```python
|
||||
# In period_building.py build_periods():
|
||||
for price_data in all_prices:
|
||||
starts_at = time.get_interval_time(price_data)
|
||||
date_key = starts_at.date()
|
||||
|
||||
# CRITICAL: Use interval's own day, not period_start_date
|
||||
ref_date = date_key
|
||||
|
||||
criteria = TibberPricesIntervalCriteria(
|
||||
ref_price=ref_prices[ref_date], # Interval's day
|
||||
avg_price=avg_prices[ref_date], # Interval's day
|
||||
flex=flex,
|
||||
min_distance_from_avg=min_distance_from_avg,
|
||||
reverse_sort=reverse_sort,
|
||||
)
|
||||
```
|
||||
|
||||
**Why Per-Day Evaluation?**
|
||||
|
||||
Periods can cross midnight (e.g., 23:45 → 01:00). Each day has independent reference prices calculated from its 96 intervals.
|
||||
|
||||
**Example showing the problem with period-start-day approach:**
|
||||
|
||||
```
|
||||
Day 1 (2025-11-21): Cheap day
|
||||
daily_min = 10 ct, daily_avg = 20 ct, flex = 15%
|
||||
Criteria: price ≤ 11.5 ct (10 + 10×0.15)
|
||||
|
||||
Day 2 (2025-11-22): Expensive day
|
||||
daily_min = 20 ct, daily_avg = 30 ct, flex = 15%
|
||||
Criteria: price ≤ 23 ct (20 + 20×0.15)
|
||||
|
||||
Period crossing midnight: 23:45 Day 1 → 00:15 Day 2
|
||||
23:45 (Day 1): 11 ct → ✅ Passes (11 ≤ 11.5)
|
||||
00:00 (Day 2): 21 ct → Should this pass?
|
||||
|
||||
❌ WRONG (using period start day):
|
||||
00:00 evaluated against Day 1's 11.5 ct threshold
|
||||
21 ct > 11.5 ct → Fails
|
||||
But 21ct IS cheap on Day 2 (min=20ct)!
|
||||
|
||||
✅ CORRECT (using interval's own day):
|
||||
00:00 evaluated against Day 2's 23 ct threshold
|
||||
21 ct ≤ 23 ct → Passes
|
||||
Correctly identified as cheap relative to Day 2
|
||||
```
|
||||
|
||||
**Trade-off: Periods May Break at Midnight**
|
||||
|
||||
When days differ significantly, period can split:
|
||||
```
|
||||
Day 1: Min=10ct, Avg=20ct, 23:45=11ct → ✅ Cheap (relative to Day 1)
|
||||
Day 2: Min=25ct, Avg=35ct, 00:00=21ct → ❌ Expensive (relative to Day 2)
|
||||
Result: Period stops at 23:45, new period starts later
|
||||
```
|
||||
|
||||
This is **mathematically correct** - 21ct is genuinely expensive on a day where minimum is 25ct.
|
||||
|
||||
**Market Reality Explains Price Jumps:**
|
||||
|
||||
Day-ahead electricity markets (EPEX SPOT) set prices at 12:00 CET for all next-day hours:
|
||||
- Late intervals (23:45): Priced ~36h before delivery → high forecast uncertainty → risk premium
|
||||
- Early intervals (00:00): Priced ~12h before delivery → better forecasts → lower risk buffer
|
||||
|
||||
This explains why absolute prices jump at midnight despite minimal demand changes.
|
||||
|
||||
**User-Facing Solution (Nov 2025):**
|
||||
|
||||
Added per-period day volatility attributes to detect when classification changes are meaningful:
|
||||
- `day_volatility_%`: Percentage spread (span/avg × 100)
|
||||
- `day_price_min`, `day_price_max`, `day_price_span`: Daily price range (ct/øre)
|
||||
|
||||
Automations can check volatility before acting:
|
||||
```yaml
|
||||
condition:
|
||||
- condition: template
|
||||
value_template: >
|
||||
{{ state_attr('binary_sensor.tibber_home_best_price_period', 'day_volatility_%') | float(0) > 15 }}
|
||||
```
|
||||
|
||||
Low volatility (< 15%) means classification changes are less economically significant.
|
||||
|
||||
**Alternative Approaches Rejected:**
|
||||
|
||||
1. **Use period start day for all intervals**
|
||||
- Problem: Mathematically incorrect - lends cheap day's criteria to expensive day
|
||||
- Rejected: Violates relative evaluation principle
|
||||
|
||||
2. **Adjust flex/distance at midnight**
|
||||
- Problem: Complex, unpredictable, hides market reality
|
||||
- Rejected: Users should understand price context, not have it hidden
|
||||
|
||||
3. **Split at midnight always**
|
||||
- Problem: Artificially fragments natural periods
|
||||
- Rejected: Worse user experience
|
||||
|
||||
2. **Use next day's reference** - Switch reference at midnight
|
||||
- Problem: Period criteria inconsistent across its duration
|
||||
4. **Use next day's reference after midnight**
|
||||
- Problem: Period criteria inconsistent across duration
|
||||
- Rejected: Confusing and unpredictable
|
||||
|
||||
3. **Current approach** - Lock to start day's reference
|
||||
- Benefit: Consistent criteria throughout period
|
||||
- Drawback: Period may "spill" into different price context
|
||||
**Status:** Per-day evaluation is intentional design prioritizing mathematical correctness.
|
||||
|
||||
**Status:** Current approach is intentional design choice
|
||||
**See Also:**
|
||||
- User documentation: `docs/user/period-calculation.md` → "Midnight Price Classification Changes"
|
||||
- Implementation: `coordinator/period_handlers/period_building.py` (line ~126: `ref_date = date_key`)
|
||||
- Attributes: `coordinator/period_handlers/period_statistics.py` (day volatility calculation)
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -4,14 +4,241 @@
|
|||
|
||||
> **Tip:** For dashboard examples with dynamic icons and colors, see the **[Dynamic Icons Guide](dynamic-icons.md)** and **[Dynamic Icon Colors Guide](icon-colors.md)**.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Price-Based Automations](#price-based-automations)
|
||||
- [Volatility-Aware Automations](#volatility-aware-automations)
|
||||
- [Best Hour Detection](#best-hour-detection)
|
||||
- [ApexCharts Cards](#apexcharts-cards)
|
||||
|
||||
---
|
||||
|
||||
## Price-Based Automations
|
||||
|
||||
Coming soon...
|
||||
|
||||
---
|
||||
|
||||
## Volatility-Aware Automations
|
||||
|
||||
These examples show how to handle low-volatility days where period classifications may flip at midnight despite minimal absolute price changes.
|
||||
|
||||
### Use Case: Only Act on High-Volatility Days
|
||||
|
||||
On days with low price variation (< 15% volatility), the difference between "cheap" and "expensive" periods is minimal. This automation only runs appliances when the savings are meaningful:
|
||||
|
||||
```yaml
|
||||
automation:
|
||||
- alias: "Dishwasher - Best Price (High Volatility Only)"
|
||||
description: "Start dishwasher during Best Price period, but only on days with meaningful price differences"
|
||||
trigger:
|
||||
- platform: state
|
||||
entity_id: binary_sensor.tibber_home_best_price_period
|
||||
to: "on"
|
||||
condition:
|
||||
# Only act if volatility > 15% (meaningful savings)
|
||||
- condition: numeric_state
|
||||
entity_id: sensor.tibber_home_volatility_today
|
||||
above: 15
|
||||
# Optional: Ensure dishwasher is idle and door closed
|
||||
- condition: state
|
||||
entity_id: binary_sensor.dishwasher_door
|
||||
state: "off"
|
||||
action:
|
||||
- service: switch.turn_on
|
||||
target:
|
||||
entity_id: switch.dishwasher_smart_plug
|
||||
- service: notify.mobile_app
|
||||
data:
|
||||
message: "Dishwasher started during Best Price period ({{ states('sensor.tibber_home_current_interval_price_ct') }} ct/kWh, volatility {{ states('sensor.tibber_home_volatility_today') }}%)"
|
||||
```
|
||||
|
||||
**Why this works:**
|
||||
- On high-volatility days (e.g., 25% span), Best Price periods save 5-10 ct/kWh
|
||||
- On low-volatility days (e.g., 8% span), savings are only 1-2 ct/kWh
|
||||
- User can manually start dishwasher on low-volatility days without automation interference
|
||||
|
||||
### Use Case: Absolute Price Threshold
|
||||
|
||||
Instead of relying on relative classification, check if the absolute price is cheap enough:
|
||||
|
||||
```yaml
|
||||
automation:
|
||||
- alias: "Water Heater - Cheap Enough"
|
||||
description: "Heat water when price is below absolute threshold, regardless of period classification"
|
||||
trigger:
|
||||
- platform: state
|
||||
entity_id: binary_sensor.tibber_home_best_price_period
|
||||
to: "on"
|
||||
condition:
|
||||
# Absolute threshold: Only run if < 20 ct/kWh
|
||||
- condition: numeric_state
|
||||
entity_id: sensor.tibber_home_current_interval_price_ct
|
||||
below: 20
|
||||
# Optional: Check water temperature
|
||||
- condition: numeric_state
|
||||
entity_id: sensor.water_heater_temperature
|
||||
below: 55 # Only heat if below 55°C
|
||||
action:
|
||||
- service: switch.turn_on
|
||||
target:
|
||||
entity_id: switch.water_heater
|
||||
- delay:
|
||||
hours: 2 # Heat for 2 hours
|
||||
- service: switch.turn_off
|
||||
target:
|
||||
entity_id: switch.water_heater
|
||||
```
|
||||
|
||||
**Why this works:**
|
||||
- Period classification can flip at midnight on low-volatility days
|
||||
- Absolute threshold (20 ct/kWh) is stable across midnight boundary
|
||||
- User sets their own "cheap enough" price based on local rates
|
||||
|
||||
### Use Case: Combined Volatility and Price Check
|
||||
|
||||
Most robust approach: Check both volatility and absolute price:
|
||||
|
||||
```yaml
|
||||
automation:
|
||||
- alias: "EV Charging - Smart Strategy"
|
||||
description: "Charge EV using volatility-aware logic"
|
||||
trigger:
|
||||
- platform: state
|
||||
entity_id: binary_sensor.tibber_home_best_price_period
|
||||
to: "on"
|
||||
condition:
|
||||
# Check battery level
|
||||
- condition: numeric_state
|
||||
entity_id: sensor.ev_battery_level
|
||||
below: 80
|
||||
# Strategy: High volatility OR cheap enough
|
||||
- condition: or
|
||||
conditions:
|
||||
# Path 1: High volatility day - trust period classification
|
||||
- condition: numeric_state
|
||||
entity_id: sensor.tibber_home_volatility_today
|
||||
above: 15
|
||||
# Path 2: Low volatility but price is genuinely cheap
|
||||
- condition: numeric_state
|
||||
entity_id: sensor.tibber_home_current_interval_price_ct
|
||||
below: 18
|
||||
action:
|
||||
- service: switch.turn_on
|
||||
target:
|
||||
entity_id: switch.ev_charger
|
||||
- service: notify.mobile_app
|
||||
data:
|
||||
message: >
|
||||
EV charging started: {{ states('sensor.tibber_home_current_interval_price_ct') }} ct/kWh
|
||||
(Volatility: {{ states('sensor.tibber_home_volatility_today') }}%)
|
||||
```
|
||||
|
||||
**Why this works:**
|
||||
- On high-volatility days (> 15%): Trust the Best Price classification
|
||||
- On low-volatility days (< 15%): Only charge if price is actually cheap (< 18 ct/kWh)
|
||||
- Handles midnight flips gracefully: Continues charging if price stays cheap
|
||||
|
||||
### Use Case: Ignore Period Flips During Active Period
|
||||
|
||||
Prevent automations from stopping mid-cycle when a period flips at midnight:
|
||||
|
||||
```yaml
|
||||
automation:
|
||||
- alias: "Washing Machine - Complete Cycle"
|
||||
description: "Start washing machine during Best Price, ignore midnight flips"
|
||||
trigger:
|
||||
- platform: state
|
||||
entity_id: binary_sensor.tibber_home_best_price_period
|
||||
to: "on"
|
||||
condition:
|
||||
# Only start if washing machine is idle
|
||||
- condition: state
|
||||
entity_id: sensor.washing_machine_state
|
||||
state: "idle"
|
||||
# And volatility is meaningful
|
||||
- condition: numeric_state
|
||||
entity_id: sensor.tibber_home_volatility_today
|
||||
above: 15
|
||||
action:
|
||||
- service: button.press
|
||||
target:
|
||||
entity_id: button.washing_machine_eco_program
|
||||
# Create input_boolean to track active cycle
|
||||
- service: input_boolean.turn_on
|
||||
target:
|
||||
entity_id: input_boolean.washing_machine_auto_started
|
||||
|
||||
# Separate automation: Clear flag when cycle completes
|
||||
- alias: "Washing Machine - Cycle Complete"
|
||||
trigger:
|
||||
- platform: state
|
||||
entity_id: sensor.washing_machine_state
|
||||
to: "finished"
|
||||
condition:
|
||||
# Only clear flag if we auto-started it
|
||||
- condition: state
|
||||
entity_id: input_boolean.washing_machine_auto_started
|
||||
state: "on"
|
||||
action:
|
||||
- service: input_boolean.turn_off
|
||||
target:
|
||||
entity_id: input_boolean.washing_machine_auto_started
|
||||
- service: notify.mobile_app
|
||||
data:
|
||||
message: "Washing cycle complete"
|
||||
```
|
||||
|
||||
**Why this works:**
|
||||
- Uses `input_boolean` to track auto-started cycles
|
||||
- Won't trigger multiple times if period flips during the 2-3 hour wash cycle
|
||||
- Only triggers on "off" → "on" transitions, not during "on" → "on" continuity
|
||||
|
||||
### Use Case: Per-Period Day Volatility
|
||||
|
||||
The simplest approach: Use the period's day volatility attribute directly:
|
||||
|
||||
```yaml
|
||||
automation:
|
||||
- alias: "Heat Pump - Smart Heating"
|
||||
trigger:
|
||||
- platform: state
|
||||
entity_id: binary_sensor.tibber_home_best_price_period
|
||||
to: "on"
|
||||
condition:
|
||||
# Check if the PERIOD'S DAY has meaningful volatility
|
||||
- condition: template
|
||||
value_template: >
|
||||
{{ state_attr('binary_sensor.tibber_home_best_price_period', 'day_volatility_%') | float(0) > 15 }}
|
||||
action:
|
||||
- service: climate.set_temperature
|
||||
target:
|
||||
entity_id: climate.heat_pump
|
||||
data:
|
||||
temperature: 22 # Boost temperature during cheap period
|
||||
```
|
||||
|
||||
**Available per-period attributes:**
|
||||
- `day_volatility_%`: Percentage volatility of the period's day (e.g., 8.2 for 8.2%)
|
||||
- `day_price_min`: Minimum price of the day in minor currency (ct/øre)
|
||||
- `day_price_max`: Maximum price of the day in minor currency (ct/øre)
|
||||
- `day_price_span`: Absolute difference (max - min) in minor currency (ct/øre)
|
||||
|
||||
These attributes are available on both `binary_sensor.tibber_home_best_price_period` and `binary_sensor.tibber_home_peak_price_period`.
|
||||
|
||||
**Why this works:**
|
||||
- Each period knows its day's volatility
|
||||
- No need to query separate sensors
|
||||
- Template checks if saving is meaningful (> 15% volatility)
|
||||
|
||||
---
|
||||
|
||||
## Best Hour Detection
|
||||
|
||||
Coming soon...
|
||||
|
||||
---
|
||||
|
||||
## ApexCharts Cards
|
||||
|
||||
Coming soon...
|
||||
|
|
|
|||
|
|
@ -10,6 +10,9 @@ Learn how Best Price and Peak Price periods work, and how to configure them for
|
|||
- [Understanding Relaxation](#understanding-relaxation)
|
||||
- [Common Scenarios](#common-scenarios)
|
||||
- [Troubleshooting](#troubleshooting)
|
||||
- [No Periods Found](#no-periods-found)
|
||||
- [Periods Split Into Small Pieces](#periods-split-into-small-pieces)
|
||||
- [Midnight Price Classification Changes](#midnight-price-classification-changes)
|
||||
- [Advanced Topics](#advanced-topics)
|
||||
|
||||
---
|
||||
|
|
@ -238,6 +241,8 @@ best_price_max_level: cheap # Only show if at least one interval is CHEAP
|
|||
|
||||
**Use case:** "Only notify me when prices are objectively cheap/expensive"
|
||||
|
||||
**ℹ️ Volatility Thresholds:** The level filter also supports volatility-based levels (`volatility_low`, `volatility_medium`, `volatility_high`). These use **fixed internal thresholds** (LOW < 10%, MEDIUM < 20%, HIGH ≥ 20%) that are separate from the sensor volatility thresholds you configure in the UI. This separation ensures that changing sensor display preferences doesn't affect period calculation behavior.
|
||||
|
||||
#### Gap Tolerance (for Level Filter)
|
||||
|
||||
**What:** Allow some "mediocre" intervals within an otherwise good period
|
||||
|
|
@ -523,6 +528,136 @@ period_interval_smoothed_count: 2 # Number of price spikes smoothed
|
|||
period_interval_level_gap_count: 1 # Number of "mediocre" intervals tolerated
|
||||
```
|
||||
|
||||
### 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.
|
||||
|
||||
**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
|
||||
|
||||
**Example:**
|
||||
|
||||
```yaml
|
||||
# Day 1 (low volatility, narrow range)
|
||||
Price range: 18-22 ct/kWh (4 ct span)
|
||||
Daily average: 20 ct/kWh
|
||||
23:45: 18.5 ct/kWh → 7.5% below average → BEST PRICE ✅
|
||||
|
||||
# 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 ❌
|
||||
|
||||
# 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
|
||||
```
|
||||
|
||||
**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 to Detect:**
|
||||
|
||||
Check the volatility sensors to understand if a period flip is meaningful:
|
||||
|
||||
```yaml
|
||||
# Check daily volatility (available in integration)
|
||||
sensor.tibber_home_volatility_today: 8.2% # Low volatility
|
||||
sensor.tibber_home_volatility_tomorrow: 7.9% # Also low
|
||||
|
||||
# Low volatility (< 15%) means:
|
||||
# - Small absolute price differences between periods
|
||||
# - Classification changes may not be economically significant
|
||||
# - Consider ignoring period classification on such days
|
||||
```
|
||||
|
||||
**Handling in Automations:**
|
||||
|
||||
You can make your automations volatility-aware:
|
||||
|
||||
```yaml
|
||||
# Option 1: Only act on high-volatility days
|
||||
automation:
|
||||
- alias: "Dishwasher - Best Price (High Volatility Only)"
|
||||
trigger:
|
||||
- platform: state
|
||||
entity_id: binary_sensor.tibber_home_best_price_period
|
||||
to: "on"
|
||||
condition:
|
||||
- condition: numeric_state
|
||||
entity_id: sensor.tibber_home_volatility_today
|
||||
above: 15 # Only act if volatility > 15%
|
||||
action:
|
||||
- service: switch.turn_on
|
||||
entity_id: switch.dishwasher
|
||||
|
||||
# Option 2: Check absolute price, not just classification
|
||||
automation:
|
||||
- alias: "Heat Water - Cheap Enough"
|
||||
trigger:
|
||||
- platform: state
|
||||
entity_id: binary_sensor.tibber_home_best_price_period
|
||||
to: "on"
|
||||
condition:
|
||||
- condition: numeric_state
|
||||
entity_id: sensor.tibber_home_current_interval_price_ct
|
||||
below: 20 # Absolute threshold: < 20 ct/kWh
|
||||
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.tibber_home_best_price_period
|
||||
to: "on"
|
||||
condition:
|
||||
# Check if the period's day has meaningful volatility
|
||||
- condition: template
|
||||
value_template: >
|
||||
{{ state_attr('binary_sensor.tibber_home_best_price_period', 'day_volatility_%') | float(0) > 15 }}
|
||||
action:
|
||||
- service: switch.turn_on
|
||||
entity_id: switch.ev_charger
|
||||
```
|
||||
|
||||
**Available Per-Period Attributes:**
|
||||
|
||||
Each period sensor exposes day volatility and price statistics:
|
||||
|
||||
```yaml
|
||||
binary_sensor.tibber_home_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
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
---
|
||||
|
||||
## Advanced Topics
|
||||
|
|
|
|||
Loading…
Reference in a new issue