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:
Julian Pawlowski 2025-11-21 23:18:46 +00:00
parent dd12f97207
commit 47b0a298d4
7 changed files with 582 additions and 56 deletions

View file

@ -145,6 +145,7 @@ def calculate_periods(
price_context = { price_context = {
"ref_prices": ref_prices, "ref_prices": ref_prices,
"avg_prices": avg_price_by_day, "avg_prices": avg_price_by_day,
"intervals_by_day": intervals_by_day, # Needed for day volatility calculation
"flex": flex, "flex": flex,
"min_distance_from_avg": min_distance_from_avg, "min_distance_from_avg": min_distance_from_avg,
} }

View file

@ -65,9 +65,10 @@ def build_periods( # noqa: PLR0913, PLR0915, PLR0912 - Complex period building
""" """
Build periods, allowing periods to cross midnight (day boundary). Build periods, allowing periods to cross midnight (day boundary).
Periods can span multiple days. Each period uses the reference price (min/max) from Periods can span multiple days. Each interval is evaluated against the reference
the day when the period started, ensuring consistent filtering criteria throughout price (min/max) and average price of its own day. This ensures fair filtering
the period even when crossing midnight. criteria even when periods cross midnight, where prices can jump significantly
due to different forecasting uncertainty (prices at day end vs. day start).
Args: Args:
all_prices: All price data points all_prices: All price data points
@ -105,7 +106,6 @@ def build_periods( # noqa: PLR0913, PLR0915, PLR0912 - Complex period building
periods: list[list[dict]] = [] periods: list[list[dict]] = []
current_period: 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 consecutive_gaps = 0 # Track consecutive intervals that deviate by 1 level step
intervals_checked = 0 intervals_checked = 0
intervals_filtered_by_level = 0 intervals_filtered_by_level = 0
@ -125,11 +125,14 @@ def build_periods( # noqa: PLR0913, PLR0915, PLR0912 - Complex period building
intervals_checked += 1 intervals_checked += 1
# Use reference price from period start day (for consistency across midnight) # CRITICAL: Always use reference price from the interval's own day
# If no period active, use current interval's day # Each interval must meet the criteria of its own day, not the period start day.
ref_date = period_start_date if period_start_date is not None else date_key # 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( criteria = TibberPricesIntervalCriteria(
ref_price=ref_prices[ref_date], ref_price=ref_prices[ref_date],
avg_price=avg_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 # Add to period if all criteria are met
if in_flex and meets_min_distance and meets_level: 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( current_period.append(
{ {
"interval_hour": starts_at.hour, "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 # Criteria no longer met, end current period
periods.append(current_period) periods.append(current_period)
current_period = [] current_period = []
period_start_date = None # Reset period start date
consecutive_gaps = 0 # Reset gap counter consecutive_gaps = 0 # Reset gap counter
# Add final period if exists # Add final period if exists

View file

@ -119,6 +119,7 @@ def build_period_summary_dict(
stats: TibberPricesPeriodStatistics, stats: TibberPricesPeriodStatistics,
*, *,
reverse_sort: bool, reverse_sort: bool,
price_context: dict[str, Any] | None = None,
) -> dict: ) -> dict:
""" """
Build the complete period summary dictionary. Build the complete period summary dictionary.
@ -127,6 +128,7 @@ def build_period_summary_dict(
period_data: Period timing and position data period_data: Period timing and position data
stats: Calculated period statistics stats: Calculated period statistics
reverse_sort: True for peak price, False for best price (keyword-only) 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: Returns:
Complete period summary dictionary following attribute ordering 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: if stats.period_price_diff_pct is not None:
summary["period_price_diff_from_daily_min_%"] = stats.period_price_diff_pct 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 return summary
@ -306,7 +332,9 @@ def extract_period_summaries(
) )
# Build complete period summary # 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 # Add smoothing information if any intervals benefited from smoothing
if smoothed_impactful_count > 0: if smoothed_impactful_count > 0:

View file

@ -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]]: 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, Periods crossing midnight are assigned to ALL affected days.
not the day they start. Example: Period 23:00 yesterday - 02:00 today counts Example: Period 23:00 yesterday - 02:00 today appears in BOTH days.
as "today" since it ends today.
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: Args:
periods: List of period summary dicts with "start" and "end" datetime periods: List of period summary dicts with "start" and "end" datetime
Returns: 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]] = {} periods_by_day: dict[date, list[dict]] = {}
for period in periods: for period in periods:
# Use end time for grouping so periods crossing midnight are counted start_time = period.get("start")
# towards the day they end (more relevant for min_periods check)
end_time = period.get("end") end_time = period.get("end")
if end_time:
day = end_time.date() if not start_time or not end_time:
periods_by_day.setdefault(day, []).append(period) 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 return periods_by_day
@ -224,18 +239,10 @@ def calculate_periods_with_relaxation( # noqa: PLR0913, PLR0915 - Per-day relax
INDENT_L0, INDENT_L0,
) )
# Validate we have price data for today/future # Validate we have price data
today = time.now().date() if not all_prices:
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
_LOGGER.warning( _LOGGER.warning(
"No price data available for today/future - cannot calculate periods", "No price data available - cannot calculate periods",
) )
return {"periods": [], "metadata": {}, "reference_data": {}}, { return {"periods": [], "metadata": {}, "reference_data": {}}, {
"relaxation_active": False, "relaxation_active": False,
@ -244,7 +251,7 @@ def calculate_periods_with_relaxation( # noqa: PLR0913, PLR0915 - Per-day relax
"periods_found": 0, "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) prices_by_day = group_prices_by_day(all_prices, time=time)
total_days = len(prices_by_day) total_days = len(prices_by_day)
@ -253,13 +260,14 @@ def calculate_periods_with_relaxation( # noqa: PLR0913, PLR0915 - Per-day relax
total_days, total_days,
) )
_LOGGER.debug( _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, INDENT_L1,
len(future_prices), len(all_prices),
) )
# === BASELINE CALCULATION (process ALL prices together) === # === BASELINE CALCULATION (process ALL prices together, including yesterday) ===
baseline_result = calculate_periods(future_prices, config=config, time=time) # 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"] all_periods = baseline_result["periods"]
# Count periods per day for min_periods check # 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 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( relaxed_result, relax_metadata = relax_all_prices(
all_prices=future_prices, all_prices=all_prices,
config=config, config=config,
min_periods=min_periods, min_periods=min_periods,
max_relaxation_attempts=max_relaxation_attempts, 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. Relax filters for all prices until min_periods per day is reached.
Strategy: Try increasing flex by 3% increments, then relax level filter. Strategy: Try increasing flex by 3% increments, then relax level filter.
Processes all prices together, allowing periods to cross midnight boundaries. Processes all prices together (yesterday+today+tomorrow), allowing periods
Returns when ALL days have min_periods (or max attempts exhausted). to cross midnight boundaries. Returns when ALL days have min_periods
(or max attempts exhausted).
Args: Args:
all_prices: All price intervals (today + future) all_prices: All price intervals (yesterday+today+tomorrow)
config: Base period configuration config: Base period configuration
min_periods: Target number of periods PER DAY min_periods: Target number of periods PER DAY
max_relaxation_attempts: Maximum flex levels to try max_relaxation_attempts: Maximum flex levels to try

View file

@ -60,6 +60,42 @@ meets_distance = price >= (daily_avg × (1 + min_distance/100))
**Logic:** See `level_filtering.py` for gap tolerance details. **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 ## 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 **Current Behavior:** Periods can cross midnight naturally
**Issue:** Period starting 23:45 continues into next day **Design Principle:** Each interval is evaluated using its **own day's** reference prices (daily min/max/avg).
- Uses Day 1's daily_min as reference
- May be confusing when Day 2's prices very different
**Alternative Approaches Considered:** **Implementation:**
1. **Split at midnight** - Always keep periods within calendar day ```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 - Problem: Artificially fragments natural periods
- Rejected: Worse user experience - Rejected: Worse user experience
2. **Use next day's reference** - Switch reference at midnight 4. **Use next day's reference after midnight**
- Problem: Period criteria inconsistent across its duration - Problem: Period criteria inconsistent across duration
- Rejected: Confusing and unpredictable - Rejected: Confusing and unpredictable
3. **Current approach** - Lock to start day's reference **Status:** Per-day evaluation is intentional design prioritizing mathematical correctness.
- Benefit: Consistent criteria throughout period
- Drawback: Period may "spill" into different price context
**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)
--- ---

View file

@ -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)**. > **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 ## Price-Based Automations
Coming soon... 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 ## Best Hour Detection
Coming soon... Coming soon...
---
## ApexCharts Cards ## ApexCharts Cards
Coming soon... Coming soon...

View file

@ -10,6 +10,9 @@ Learn how Best Price and Peak Price periods work, and how to configure them for
- [Understanding Relaxation](#understanding-relaxation) - [Understanding Relaxation](#understanding-relaxation)
- [Common Scenarios](#common-scenarios) - [Common Scenarios](#common-scenarios)
- [Troubleshooting](#troubleshooting) - [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) - [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" **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) #### Gap Tolerance (for Level Filter)
**What:** Allow some "mediocre" intervals within an otherwise good period **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 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 ## Advanced Topics